Add copy and paste
This commit is contained in:
parent
5164d7a0a9
commit
c6a8b944e5
|
|
@ -1,3 +1,4 @@
|
|||
use std::sync::Arc;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
/// Audio clip instance ID type
|
||||
|
|
@ -35,6 +36,13 @@ pub struct AudioClipInstance {
|
|||
|
||||
/// Clip-level gain
|
||||
pub gain: f32,
|
||||
|
||||
/// Per-instance read-ahead buffer for compressed audio streaming.
|
||||
/// Each clip instance gets its own buffer so multiple instances of the
|
||||
/// same file (on different tracks or at different positions) don't fight
|
||||
/// over a single target_frame.
|
||||
#[serde(skip)]
|
||||
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
|
||||
}
|
||||
|
||||
/// Type alias for backwards compatibility
|
||||
|
|
@ -58,6 +66,7 @@ impl AudioClipInstance {
|
|||
external_start,
|
||||
external_duration,
|
||||
gain: 1.0,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -78,6 +87,7 @@ impl AudioClipInstance {
|
|||
external_start: start_time,
|
||||
external_duration: duration,
|
||||
gain: 1.0,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -166,11 +166,27 @@ impl ReadAheadBuffer {
|
|||
|
||||
/// Update the target frame — the file-local frame the audio callback
|
||||
/// is currently reading from. Called by `render_from_file` (consumer).
|
||||
/// Each clip instance has its own buffer, so a plain store is sufficient.
|
||||
#[inline]
|
||||
pub fn set_target_frame(&self, frame: u64) {
|
||||
self.target_frame.store(frame, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Reset the target frame to MAX before a new render cycle.
|
||||
/// If no clip calls `set_target_frame` this cycle, `has_active_target()`
|
||||
/// returns false, telling the disk reader to skip this buffer.
|
||||
#[inline]
|
||||
pub fn reset_target_frame(&self) {
|
||||
self.target_frame.store(u64::MAX, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Force-set the target frame to an exact value.
|
||||
/// Used by the disk reader's seek command where we need an absolute position.
|
||||
#[inline]
|
||||
pub fn force_target_frame(&self, frame: u64) {
|
||||
self.target_frame.store(frame, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get the target frame set by the audio callback.
|
||||
/// Called by the disk reader thread (producer).
|
||||
#[inline]
|
||||
|
|
@ -178,6 +194,12 @@ impl ReadAheadBuffer {
|
|||
self.target_frame.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Check if any clip set a target this cycle (vs still at reset value).
|
||||
#[inline]
|
||||
pub fn has_active_target(&self) -> bool {
|
||||
self.target_frame.load(Ordering::Relaxed) != u64::MAX
|
||||
}
|
||||
|
||||
/// Reset the buffer to start at `new_start` with zero valid frames.
|
||||
/// Called by the **disk reader thread** (producer) after a seek.
|
||||
pub fn reset(&self, new_start: u64) {
|
||||
|
|
@ -413,14 +435,14 @@ impl CompressedReader {
|
|||
|
||||
/// Commands sent from the engine to the disk reader thread.
|
||||
pub enum DiskReaderCommand {
|
||||
/// Start streaming a compressed file.
|
||||
/// Start streaming a compressed file for a clip instance.
|
||||
ActivateFile {
|
||||
pool_index: usize,
|
||||
reader_id: u64,
|
||||
path: PathBuf,
|
||||
buffer: Arc<ReadAheadBuffer>,
|
||||
},
|
||||
/// Stop streaming a file.
|
||||
DeactivateFile { pool_index: usize },
|
||||
/// Stop streaming for a clip instance.
|
||||
DeactivateFile { reader_id: u64 },
|
||||
/// The playhead has jumped — refill buffers from the new position.
|
||||
Seek { frame: u64 },
|
||||
/// Shut down the disk reader thread.
|
||||
|
|
@ -491,7 +513,7 @@ impl DiskReader {
|
|||
mut command_rx: rtrb::Consumer<DiskReaderCommand>,
|
||||
running: Arc<AtomicBool>,
|
||||
) {
|
||||
let mut active_files: HashMap<usize, (CompressedReader, Arc<ReadAheadBuffer>)> =
|
||||
let mut active_files: HashMap<u64, (CompressedReader, Arc<ReadAheadBuffer>)> =
|
||||
HashMap::new();
|
||||
let mut decode_buf = Vec::with_capacity(8192);
|
||||
|
||||
|
|
@ -500,14 +522,14 @@ impl DiskReader {
|
|||
while let Ok(cmd) = command_rx.pop() {
|
||||
match cmd {
|
||||
DiskReaderCommand::ActivateFile {
|
||||
pool_index,
|
||||
reader_id,
|
||||
path,
|
||||
buffer,
|
||||
} => match CompressedReader::open(&path) {
|
||||
Ok(reader) => {
|
||||
eprintln!("[DiskReader] Activated pool={}, ch={}, sr={}, path={:?}",
|
||||
pool_index, reader.channels, reader.sample_rate, path);
|
||||
active_files.insert(pool_index, (reader, buffer));
|
||||
eprintln!("[DiskReader] Activated reader={}, ch={}, sr={}, path={:?}",
|
||||
reader_id, reader.channels, reader.sample_rate, path);
|
||||
active_files.insert(reader_id, (reader, buffer));
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
|
|
@ -516,12 +538,12 @@ impl DiskReader {
|
|||
);
|
||||
}
|
||||
},
|
||||
DiskReaderCommand::DeactivateFile { pool_index } => {
|
||||
active_files.remove(&pool_index);
|
||||
DiskReaderCommand::DeactivateFile { reader_id } => {
|
||||
active_files.remove(&reader_id);
|
||||
}
|
||||
DiskReaderCommand::Seek { frame } => {
|
||||
for (_, (reader, buffer)) in active_files.iter_mut() {
|
||||
buffer.set_target_frame(frame);
|
||||
buffer.force_target_frame(frame);
|
||||
buffer.reset(frame);
|
||||
if let Err(e) = reader.seek(frame) {
|
||||
eprintln!("[DiskReader] Seek error: {}", e);
|
||||
|
|
@ -534,11 +556,15 @@ impl DiskReader {
|
|||
}
|
||||
}
|
||||
|
||||
// Fill each active file's buffer ahead of its target frame.
|
||||
// Each file's target_frame is set by the audio callback in
|
||||
// render_from_file, giving the file-local frame being read.
|
||||
// This is independent of the global engine playhead.
|
||||
for (_pool_index, (reader, buffer)) in active_files.iter_mut() {
|
||||
// Fill each active reader's buffer ahead of its target frame.
|
||||
// Each clip instance has its own buffer and target_frame, set by
|
||||
// render_from_file during the audio callback.
|
||||
for (_reader_id, (reader, buffer)) in active_files.iter_mut() {
|
||||
// Skip files where no clip is currently playing
|
||||
if !buffer.has_active_target() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let target = buffer.target_frame();
|
||||
let buf_start = buffer.start_frame();
|
||||
let buf_valid = buffer.valid_frames_count();
|
||||
|
|
@ -578,8 +604,8 @@ impl DiskReader {
|
|||
let was_empty = buffer.valid_frames_count() == 0;
|
||||
buffer.write_samples(&decode_buf, frames);
|
||||
if was_empty {
|
||||
eprintln!("[DiskReader] pool={}: first fill, {} frames, buf_start={}, valid={}",
|
||||
_pool_index, frames, buffer.start_frame(), buffer.valid_frames_count());
|
||||
eprintln!("[DiskReader] reader={}: first fill, {} frames, buf_start={}, valid={}",
|
||||
_reader_id, frames, buffer.start_frame(), buffer.valid_frames_count());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
|
|
|||
|
|
@ -313,6 +313,9 @@ impl Engine {
|
|||
// Convert playhead from frames to seconds for timeline-based rendering
|
||||
let playhead_seconds = self.playhead as f64 / self.sample_rate as f64;
|
||||
|
||||
// Reset per-clip read-ahead targets before rendering.
|
||||
self.project.reset_read_ahead_targets();
|
||||
|
||||
// Render the entire project hierarchy into the mix buffer
|
||||
// Note: We need to use a raw pointer to avoid borrow checker issues
|
||||
// The midi_clip_pool is part of project, so we extract a reference before mutable borrow
|
||||
|
|
@ -798,6 +801,12 @@ impl Engine {
|
|||
let _ = self.project.remove_midi_clip(track_id, instance_id);
|
||||
}
|
||||
Command::RemoveAudioClip(track_id, instance_id) => {
|
||||
// Deactivate the per-clip disk reader before removing
|
||||
if let Some(ref mut dr) = self.disk_reader {
|
||||
dr.send(crate::audio::disk_reader::DiskReaderCommand::DeactivateFile {
|
||||
reader_id: instance_id as u64,
|
||||
});
|
||||
}
|
||||
// Remove an audio clip instance from a track (for undo/redo support)
|
||||
let _ = self.project.remove_audio_clip(track_id, instance_id);
|
||||
}
|
||||
|
|
@ -1754,7 +1763,7 @@ impl Engine {
|
|||
(metadata.duration * metadata.sample_rate as f64).ceil() as u64
|
||||
});
|
||||
|
||||
let mut audio_file = crate::audio::pool::AudioFile::from_compressed(
|
||||
let audio_file = crate::audio::pool::AudioFile::from_compressed(
|
||||
path.to_path_buf(),
|
||||
metadata.channels,
|
||||
metadata.sample_rate,
|
||||
|
|
@ -1762,25 +1771,11 @@ impl Engine {
|
|||
ext,
|
||||
);
|
||||
|
||||
let buffer = crate::audio::disk_reader::DiskReader::create_buffer(
|
||||
metadata.sample_rate,
|
||||
metadata.channels,
|
||||
);
|
||||
audio_file.read_ahead = Some(buffer.clone());
|
||||
|
||||
let idx = self.audio_pool.add_file(audio_file);
|
||||
|
||||
eprintln!("[ENGINE] Compressed: total_frames={}, pool_index={}, has_disk_reader={}",
|
||||
total_frames, idx, self.disk_reader.is_some());
|
||||
|
||||
if let Some(ref mut dr) = self.disk_reader {
|
||||
dr.send(crate::audio::disk_reader::DiskReaderCommand::ActivateFile {
|
||||
pool_index: idx,
|
||||
path: path.to_path_buf(),
|
||||
buffer,
|
||||
});
|
||||
}
|
||||
|
||||
// Spawn background thread to decode file progressively for waveform display
|
||||
let bg_tx = self.chunk_generation_tx.clone();
|
||||
let bg_path = path.to_path_buf();
|
||||
|
|
@ -2138,6 +2133,28 @@ impl Engine {
|
|||
let instance_id = self.next_clip_id;
|
||||
self.next_clip_id += 1;
|
||||
|
||||
// For compressed files, create a per-clip read-ahead buffer
|
||||
let read_ahead = if let Some(file) = self.audio_pool.get_file(pool_index) {
|
||||
if matches!(file.storage, crate::audio::pool::AudioStorage::Compressed { .. }) {
|
||||
let buffer = crate::audio::disk_reader::DiskReader::create_buffer(
|
||||
file.sample_rate,
|
||||
file.channels,
|
||||
);
|
||||
if let Some(ref mut dr) = self.disk_reader {
|
||||
dr.send(crate::audio::disk_reader::DiskReaderCommand::ActivateFile {
|
||||
reader_id: instance_id as u64,
|
||||
path: file.path.clone(),
|
||||
buffer: buffer.clone(),
|
||||
});
|
||||
}
|
||||
Some(buffer)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let clip = AudioClipInstance {
|
||||
id: instance_id,
|
||||
audio_pool_index: pool_index,
|
||||
|
|
@ -2146,6 +2163,7 @@ impl Engine {
|
|||
external_start: start_time,
|
||||
external_duration: duration,
|
||||
gain: 1.0,
|
||||
read_ahead,
|
||||
};
|
||||
|
||||
match self.project.add_clip(track_id, clip) {
|
||||
|
|
|
|||
|
|
@ -95,9 +95,6 @@ pub struct AudioFile {
|
|||
/// Original file format (mp3, ogg, wav, flac, etc.)
|
||||
/// Used to determine if we should preserve lossy encoding during save
|
||||
pub original_format: Option<String>,
|
||||
/// Read-ahead buffer for streaming playback (Compressed files).
|
||||
/// When present, `render_from_file` reads from this buffer instead of `data()`.
|
||||
pub read_ahead: Option<Arc<super::disk_reader::ReadAheadBuffer>>,
|
||||
}
|
||||
|
||||
impl AudioFile {
|
||||
|
|
@ -111,7 +108,6 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames,
|
||||
original_format: None,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -125,7 +121,6 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames,
|
||||
original_format,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -157,7 +152,6 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames: total_frames,
|
||||
original_format: Some("wav".to_string()),
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,7 +174,6 @@ impl AudioFile {
|
|||
sample_rate,
|
||||
frames: total_frames,
|
||||
original_format,
|
||||
read_ahead: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -444,6 +437,7 @@ impl AudioClipPool {
|
|||
|
||||
/// Render audio from a file in the pool with high-quality windowed sinc interpolation
|
||||
/// start_time_seconds: position in the audio file to start reading from (in seconds)
|
||||
/// clip_read_ahead: per-clip-instance read-ahead buffer for compressed audio streaming
|
||||
/// Returns the number of samples actually rendered
|
||||
pub fn render_from_file(
|
||||
&self,
|
||||
|
|
@ -453,13 +447,14 @@ impl AudioClipPool {
|
|||
gain: f32,
|
||||
engine_sample_rate: u32,
|
||||
engine_channels: u32,
|
||||
clip_read_ahead: Option<&super::disk_reader::ReadAheadBuffer>,
|
||||
) -> usize {
|
||||
let Some(audio_file) = self.files.get(pool_index) else {
|
||||
return 0;
|
||||
};
|
||||
|
||||
let audio_data = audio_file.data();
|
||||
let read_ahead = audio_file.read_ahead.as_deref();
|
||||
let read_ahead = clip_read_ahead;
|
||||
let use_read_ahead = audio_data.is_empty();
|
||||
let src_channels = audio_file.channels as usize;
|
||||
|
||||
|
|
|
|||
|
|
@ -484,6 +484,19 @@ impl Project {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reset all per-clip read-ahead target frames before a new render cycle.
|
||||
pub fn reset_read_ahead_targets(&self) {
|
||||
for track in self.tracks.values() {
|
||||
if let TrackNode::Audio(audio_track) = track {
|
||||
for clip in &audio_track.clips {
|
||||
if let Some(ra) = clip.read_ahead.as_deref() {
|
||||
ra.reset_target_frame();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop all notes on all MIDI tracks
|
||||
pub fn stop_all_notes(&mut self) {
|
||||
for track in self.tracks.values_mut() {
|
||||
|
|
|
|||
|
|
@ -917,6 +917,7 @@ impl AudioTrack {
|
|||
combined_gain,
|
||||
sample_rate,
|
||||
channels,
|
||||
clip.read_ahead.as_deref(),
|
||||
);
|
||||
} else {
|
||||
// Looping case: need to handle wrap-around at loop boundaries
|
||||
|
|
@ -951,6 +952,7 @@ impl AudioTrack {
|
|||
combined_gain,
|
||||
sample_rate,
|
||||
channels,
|
||||
clip.read_ahead.as_deref(),
|
||||
);
|
||||
|
||||
total_rendered += rendered;
|
||||
|
|
|
|||
|
|
@ -40,3 +40,6 @@ pathdiff = "0.2"
|
|||
# Audio encoding for embedded files
|
||||
flacenc = "0.4" # For FLAC encoding (lossless)
|
||||
claxon = "0.4" # For FLAC decoding
|
||||
|
||||
# System clipboard
|
||||
arboard = "3"
|
||||
|
|
|
|||
|
|
@ -226,12 +226,14 @@ impl Action for AddClipInstanceAction {
|
|||
// For sampled audio, send AddAudioClipSync query
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
let duration = clip.duration;
|
||||
let internal_start = self.clip_instance.trim_start;
|
||||
let internal_end = self.clip_instance.trim_end.unwrap_or(clip.duration);
|
||||
let effective_duration = self.clip_instance.timeline_duration
|
||||
.unwrap_or(internal_end - internal_start);
|
||||
let start_time = self.clip_instance.timeline_start;
|
||||
let offset = self.clip_instance.trim_start;
|
||||
|
||||
let query =
|
||||
Query::AddAudioClipSync(*backend_track_id, *audio_pool_index, start_time, duration, offset);
|
||||
Query::AddAudioClipSync(*backend_track_id, *audio_pool_index, start_time, effective_duration, internal_start);
|
||||
|
||||
match controller.send_query(query)? {
|
||||
QueryResponse::AudioClipInstanceAdded(Ok(instance_id)) => {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,8 @@ pub mod rename_folder;
|
|||
pub mod delete_folder;
|
||||
pub mod move_asset_to_folder;
|
||||
pub mod update_midi_notes;
|
||||
pub mod remove_clip_instances;
|
||||
pub mod remove_shapes;
|
||||
|
||||
pub use add_clip_instance::AddClipInstanceAction;
|
||||
pub use add_effect::AddEffectAction;
|
||||
|
|
@ -48,3 +50,5 @@ pub use rename_folder::RenameFolderAction;
|
|||
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
|
||||
pub use move_asset_to_folder::MoveAssetToFolderAction;
|
||||
pub use update_midi_notes::UpdateMidiNotesAction;
|
||||
pub use remove_clip_instances::RemoveClipInstancesAction;
|
||||
pub use remove_shapes::RemoveShapesAction;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,269 @@
|
|||
//! Remove clip instances action
|
||||
//!
|
||||
//! Handles removing one or more clip instances from layers (for cut/delete).
|
||||
|
||||
use crate::action::{Action, BackendClipInstanceId, BackendContext};
|
||||
use crate::clip::ClipInstance;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that removes clip instances from layers
|
||||
pub struct RemoveClipInstancesAction {
|
||||
/// (layer_id, instance_id) pairs to remove
|
||||
removals: Vec<(Uuid, Uuid)>,
|
||||
/// Saved instances for rollback (layer_id -> ClipInstance)
|
||||
saved: Vec<(Uuid, ClipInstance)>,
|
||||
/// Saved backend mappings for rollback (instance_id -> BackendClipInstanceId)
|
||||
saved_backend_ids: HashMap<Uuid, BackendClipInstanceId>,
|
||||
}
|
||||
|
||||
impl RemoveClipInstancesAction {
|
||||
/// Create a new remove clip instances action
|
||||
pub fn new(removals: Vec<(Uuid, Uuid)>) -> Self {
|
||||
Self {
|
||||
removals,
|
||||
saved: Vec::new(),
|
||||
saved_backend_ids: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for RemoveClipInstancesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
self.saved.clear();
|
||||
|
||||
for (layer_id, instance_id) in &self.removals {
|
||||
let layer = document
|
||||
.get_layer_mut(layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
};
|
||||
|
||||
// Find and remove the instance, saving it for rollback
|
||||
if let Some(pos) = clip_instances.iter().position(|ci| ci.id == *instance_id) {
|
||||
let removed = clip_instances.remove(pos);
|
||||
self.saved.push((*layer_id, removed));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
// Re-insert saved instances
|
||||
for (layer_id, instance) in self.saved.drain(..).rev() {
|
||||
let layer = document
|
||||
.get_layer_mut(&layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", layer_id))?;
|
||||
|
||||
let clip_instances = match layer {
|
||||
AnyLayer::Vector(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &mut al.clip_instances,
|
||||
AnyLayer::Video(vl) => &mut vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &mut el.clip_instances,
|
||||
};
|
||||
|
||||
clip_instances.push(instance);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.removals.len();
|
||||
if count == 1 {
|
||||
"Delete clip instance".to_string()
|
||||
} else {
|
||||
format!("Delete {} clip instances", count)
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
for (layer_id, instance_id) in &self.removals {
|
||||
// Only process audio layers
|
||||
let layer = match document.get_layer(layer_id) {
|
||||
Some(l) => l,
|
||||
None => continue,
|
||||
};
|
||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let track_id = match backend.layer_to_track_map.get(layer_id) {
|
||||
Some(id) => *id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Remove from backend using stored mapping
|
||||
if let Some(backend_id) = backend.clip_instance_to_backend_map.remove(instance_id) {
|
||||
self.saved_backend_ids.insert(*instance_id, backend_id.clone());
|
||||
match backend_id {
|
||||
BackendClipInstanceId::Midi(midi_id) => {
|
||||
controller.remove_midi_clip(track_id, midi_id);
|
||||
}
|
||||
BackendClipInstanceId::Audio(audio_id) => {
|
||||
controller.remove_audio_clip(track_id, audio_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback_backend(
|
||||
&mut self,
|
||||
backend: &mut BackendContext,
|
||||
document: &Document,
|
||||
) -> Result<(), String> {
|
||||
use crate::clip::AudioClipType;
|
||||
|
||||
let controller = match backend.audio_controller.as_mut() {
|
||||
Some(c) => c,
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Re-add clips that were removed from backend
|
||||
for (layer_id, instance) in &self.saved {
|
||||
let layer = match document.get_layer(layer_id) {
|
||||
Some(l) => l,
|
||||
None => continue,
|
||||
};
|
||||
if !matches!(layer, AnyLayer::Audio(_)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let track_id = match backend.layer_to_track_map.get(layer_id) {
|
||||
Some(id) => *id,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let clip = match document.get_audio_clip(&instance.clip_id) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
match &clip.clip_type {
|
||||
AudioClipType::Midi { midi_clip_id } => {
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
let internal_start = instance.trim_start;
|
||||
let internal_end = instance.trim_end.unwrap_or(clip.duration);
|
||||
let external_start = instance.timeline_start;
|
||||
let external_duration = instance
|
||||
.timeline_duration
|
||||
.unwrap_or(internal_end - internal_start);
|
||||
|
||||
let midi_instance = daw_backend::MidiClipInstance::new(
|
||||
0,
|
||||
*midi_clip_id,
|
||||
internal_start,
|
||||
internal_end,
|
||||
external_start,
|
||||
external_duration,
|
||||
);
|
||||
|
||||
let query = Query::AddMidiClipInstanceSync(track_id, midi_instance);
|
||||
if let Ok(QueryResponse::MidiClipInstanceAdded(Ok(new_id))) =
|
||||
controller.send_query(query)
|
||||
{
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
instance.id,
|
||||
BackendClipInstanceId::Midi(new_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
AudioClipType::Sampled { audio_pool_index } => {
|
||||
use daw_backend::command::{Query, QueryResponse};
|
||||
|
||||
let internal_start = instance.trim_start;
|
||||
let internal_end = instance.trim_end.unwrap_or(clip.duration);
|
||||
let effective_duration = instance.timeline_duration
|
||||
.unwrap_or(internal_end - internal_start);
|
||||
let start_time = instance.timeline_start;
|
||||
|
||||
let query = Query::AddAudioClipSync(
|
||||
track_id,
|
||||
*audio_pool_index,
|
||||
start_time,
|
||||
effective_duration,
|
||||
internal_start,
|
||||
);
|
||||
if let Ok(QueryResponse::AudioClipInstanceAdded(Ok(new_id))) =
|
||||
controller.send_query(query)
|
||||
{
|
||||
backend.clip_instance_to_backend_map.insert(
|
||||
instance.id,
|
||||
BackendClipInstanceId::Audio(new_id),
|
||||
);
|
||||
}
|
||||
}
|
||||
AudioClipType::Recording => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear saved backend IDs
|
||||
self.saved_backend_ids.clear();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
|
||||
#[test]
|
||||
fn test_remove_clip_instances() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
let clip_id = Uuid::new_v4();
|
||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||
|
||||
let mut ci1 = ClipInstance::new(clip_id);
|
||||
ci1.timeline_start = 0.0;
|
||||
let id1 = ci1.id;
|
||||
|
||||
let mut ci2 = ClipInstance::new(clip_id);
|
||||
ci2.timeline_start = 5.0;
|
||||
let id2 = ci2.id;
|
||||
|
||||
vector_layer.clip_instances.push(ci1);
|
||||
vector_layer.clip_instances.push(ci2);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Remove first clip instance
|
||||
let mut action = RemoveClipInstancesAction::new(vec![(layer_id, id1)]);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.clip_instances.len(), 1);
|
||||
assert_eq!(vl.clip_instances[0].id, id2);
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.clip_instances.len(), 2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
//! Remove shapes action
|
||||
//!
|
||||
//! Handles removing shapes and shape instances from a vector layer (for cut/delete).
|
||||
|
||||
use crate::action::Action;
|
||||
use crate::document::Document;
|
||||
use crate::layer::AnyLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Action that removes shapes and their instances from a vector layer
|
||||
pub struct RemoveShapesAction {
|
||||
/// Layer ID containing the shapes
|
||||
layer_id: Uuid,
|
||||
/// Shape IDs to remove
|
||||
shape_ids: Vec<Uuid>,
|
||||
/// Shape instance IDs to remove
|
||||
instance_ids: Vec<Uuid>,
|
||||
/// Saved shapes for rollback
|
||||
saved_shapes: Vec<(Uuid, Shape)>,
|
||||
/// Saved instances for rollback
|
||||
saved_instances: Vec<ShapeInstance>,
|
||||
}
|
||||
|
||||
impl RemoveShapesAction {
|
||||
/// Create a new remove shapes action
|
||||
pub fn new(layer_id: Uuid, shape_ids: Vec<Uuid>, instance_ids: Vec<Uuid>) -> Self {
|
||||
Self {
|
||||
layer_id,
|
||||
shape_ids,
|
||||
instance_ids,
|
||||
saved_shapes: Vec::new(),
|
||||
saved_instances: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Action for RemoveShapesAction {
|
||||
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
self.saved_shapes.clear();
|
||||
self.saved_instances.clear();
|
||||
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
// Remove and save shape instances
|
||||
let mut remaining_instances = Vec::new();
|
||||
for inst in vector_layer.shape_instances.drain(..) {
|
||||
if self.instance_ids.contains(&inst.id) {
|
||||
self.saved_instances.push(inst);
|
||||
} else {
|
||||
remaining_instances.push(inst);
|
||||
}
|
||||
}
|
||||
vector_layer.shape_instances = remaining_instances;
|
||||
|
||||
// Remove and save shape definitions
|
||||
for shape_id in &self.shape_ids {
|
||||
if let Some(shape) = vector_layer.shapes.remove(shape_id) {
|
||||
self.saved_shapes.push((*shape_id, shape));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||
let layer = document
|
||||
.get_layer_mut(&self.layer_id)
|
||||
.ok_or_else(|| format!("Layer {} not found", self.layer_id))?;
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return Err("Not a vector layer".to_string()),
|
||||
};
|
||||
|
||||
// Restore shapes
|
||||
for (id, shape) in self.saved_shapes.drain(..) {
|
||||
vector_layer.shapes.insert(id, shape);
|
||||
}
|
||||
|
||||
// Restore instances
|
||||
for inst in self.saved_instances.drain(..) {
|
||||
vector_layer.shape_instances.push(inst);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn description(&self) -> String {
|
||||
let count = self.instance_ids.len();
|
||||
if count == 1 {
|
||||
"Delete shape".to_string()
|
||||
} else {
|
||||
format!("Delete {} shapes", count)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::layer::VectorLayer;
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use vello::kurbo::BezPath;
|
||||
|
||||
#[test]
|
||||
fn test_remove_shapes() {
|
||||
let mut document = Document::new("Test");
|
||||
|
||||
let mut vector_layer = VectorLayer::new("Layer 1");
|
||||
|
||||
// Add a shape and instance
|
||||
let mut path = BezPath::new();
|
||||
path.move_to((0.0, 0.0));
|
||||
path.line_to((100.0, 100.0));
|
||||
let shape = Shape::new(path);
|
||||
let shape_id = shape.id;
|
||||
let instance = ShapeInstance::new(shape_id);
|
||||
let instance_id = instance.id;
|
||||
|
||||
vector_layer.shapes.insert(shape_id, shape);
|
||||
vector_layer.shape_instances.push(instance);
|
||||
|
||||
let layer_id = document.root_mut().add_child(AnyLayer::Vector(vector_layer));
|
||||
|
||||
// Remove
|
||||
let mut action = RemoveShapesAction::new(layer_id, vec![shape_id], vec![instance_id]);
|
||||
action.execute(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert!(vl.shapes.is_empty());
|
||||
assert!(vl.shape_instances.is_empty());
|
||||
}
|
||||
|
||||
// Rollback
|
||||
action.rollback(&mut document).unwrap();
|
||||
|
||||
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
|
||||
assert_eq!(vl.shapes.len(), 1);
|
||||
assert_eq!(vl.shape_instances.len(), 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -415,16 +415,18 @@ impl Action for SplitClipInstanceAction {
|
|||
}
|
||||
|
||||
// 2. Add the new (right) instance
|
||||
let duration = clip.duration;
|
||||
let internal_start = new_instance.trim_start;
|
||||
let internal_end = new_instance.trim_end.unwrap_or(clip.duration);
|
||||
let effective_duration = new_instance.timeline_duration
|
||||
.unwrap_or(internal_end - internal_start);
|
||||
let start_time = new_instance.timeline_start;
|
||||
let offset = new_instance.trim_start;
|
||||
|
||||
let query = Query::AddAudioClipSync(
|
||||
*backend_track_id,
|
||||
*audio_pool_index,
|
||||
start_time,
|
||||
duration,
|
||||
offset,
|
||||
effective_duration,
|
||||
internal_start,
|
||||
);
|
||||
|
||||
match controller.send_query(query)? {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,283 @@
|
|||
//! Clipboard management for cut/copy/paste operations
|
||||
//!
|
||||
//! Supports multiple content types (clip instances, shapes) with
|
||||
//! cross-platform clipboard integration via arboard.
|
||||
|
||||
use crate::clip::{AudioClip, ClipInstance, ImageAsset, VectorClip, VideoClip};
|
||||
use crate::layer::{AudioLayerType, AnyLayer};
|
||||
use crate::object::ShapeInstance;
|
||||
use crate::shape::Shape;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Layer type tag for clipboard, so paste knows where clips can go
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ClipboardLayerType {
|
||||
Vector,
|
||||
Video,
|
||||
AudioSampled,
|
||||
AudioMidi,
|
||||
Effect,
|
||||
}
|
||||
|
||||
impl ClipboardLayerType {
|
||||
/// Determine the clipboard layer type from a document layer
|
||||
pub fn from_layer(layer: &AnyLayer) -> Self {
|
||||
match layer {
|
||||
AnyLayer::Vector(_) => ClipboardLayerType::Vector,
|
||||
AnyLayer::Video(_) => ClipboardLayerType::Video,
|
||||
AnyLayer::Audio(al) => match al.audio_layer_type {
|
||||
AudioLayerType::Sampled => ClipboardLayerType::AudioSampled,
|
||||
AudioLayerType::Midi => ClipboardLayerType::AudioMidi,
|
||||
},
|
||||
AnyLayer::Effect(_) => ClipboardLayerType::Effect,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
(ClipboardLayerType::Video, AnyLayer::Video(_)) => true,
|
||||
(ClipboardLayerType::AudioSampled, AnyLayer::Audio(al)) => {
|
||||
al.audio_layer_type == AudioLayerType::Sampled
|
||||
}
|
||||
(ClipboardLayerType::AudioMidi, AnyLayer::Audio(al)) => {
|
||||
al.audio_layer_type == AudioLayerType::Midi
|
||||
}
|
||||
(ClipboardLayerType::Effect, AnyLayer::Effect(_)) => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Content stored in the clipboard
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ClipboardContent {
|
||||
/// Clip instances with their referenced clip definitions
|
||||
ClipInstances {
|
||||
/// Source layer type (for paste compatibility)
|
||||
layer_type: ClipboardLayerType,
|
||||
/// The clip instances (IDs will be regenerated on paste)
|
||||
instances: Vec<ClipInstance>,
|
||||
/// Referenced audio clip definitions
|
||||
audio_clips: Vec<(Uuid, AudioClip)>,
|
||||
/// Referenced video clip definitions
|
||||
video_clips: Vec<(Uuid, VideoClip)>,
|
||||
/// Referenced vector clip definitions
|
||||
vector_clips: Vec<(Uuid, VectorClip)>,
|
||||
/// Referenced image assets
|
||||
image_assets: Vec<(Uuid, ImageAsset)>,
|
||||
},
|
||||
/// Shapes and shape instances from a vector layer
|
||||
Shapes {
|
||||
/// Shape definitions (id -> shape)
|
||||
shapes: Vec<(Uuid, Shape)>,
|
||||
/// Shape instances referencing the shapes above
|
||||
instances: Vec<ShapeInstance>,
|
||||
},
|
||||
}
|
||||
|
||||
impl ClipboardContent {
|
||||
/// Create a clone of this content with all UUIDs regenerated
|
||||
/// Returns the new content and a mapping from old to new IDs
|
||||
pub fn with_regenerated_ids(&self) -> (Self, HashMap<Uuid, Uuid>) {
|
||||
let mut id_map = HashMap::new();
|
||||
|
||||
match self {
|
||||
ClipboardContent::ClipInstances {
|
||||
layer_type,
|
||||
instances,
|
||||
audio_clips,
|
||||
video_clips,
|
||||
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();
|
||||
|
||||
(
|
||||
ClipboardContent::ClipInstances {
|
||||
layer_type: layer_type.clone(),
|
||||
instances: new_instances,
|
||||
audio_clips: new_audio_clips,
|
||||
video_clips: new_video_clips,
|
||||
vector_clips: new_vector_clips,
|
||||
image_assets: new_image_assets,
|
||||
},
|
||||
id_map,
|
||||
)
|
||||
}
|
||||
ClipboardContent::Shapes { shapes, instances } => {
|
||||
// Regenerate shape definition IDs
|
||||
let new_shapes: Vec<(Uuid, Shape)> = shapes
|
||||
.iter()
|
||||
.map(|(old_id, shape)| {
|
||||
let new_id = Uuid::new_v4();
|
||||
id_map.insert(*old_id, new_id);
|
||||
let mut new_shape = shape.clone();
|
||||
new_shape.id = new_id;
|
||||
(new_id, new_shape)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Regenerate instance IDs and remap shape_id references
|
||||
let new_instances: Vec<ShapeInstance> = 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 shape_id to new definition ID
|
||||
if let Some(new_shape_id) = id_map.get(&inst.shape_id) {
|
||||
new_inst.shape_id = *new_shape_id;
|
||||
}
|
||||
new_inst
|
||||
})
|
||||
.collect();
|
||||
|
||||
(
|
||||
ClipboardContent::Shapes {
|
||||
shapes: new_shapes,
|
||||
instances: new_instances,
|
||||
},
|
||||
id_map,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// JSON prefix for clipboard text to identify Lightningbeam content
|
||||
const CLIPBOARD_PREFIX: &str = "LIGHTNINGBEAM_CLIPBOARD:";
|
||||
|
||||
/// 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 both internal and system 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Store internally for rich access
|
||||
self.internal = Some(content);
|
||||
}
|
||||
|
||||
/// Try to paste content
|
||||
/// Returns internal clipboard if available, falls back to system clipboard JSON
|
||||
pub fn paste(&mut self) -> Option<ClipboardContent> {
|
||||
// Try internal clipboard first
|
||||
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() {
|
||||
if let Some(json) = text.strip_prefix(CLIPBOARD_PREFIX) {
|
||||
if let Ok(content) = serde_json::from_str::<ClipboardContent>(json) {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
}
|
||||
|
|
@ -40,3 +40,4 @@ pub mod planar_graph;
|
|||
pub mod file_types;
|
||||
pub mod file_io;
|
||||
pub mod export;
|
||||
pub mod clipboard;
|
||||
|
|
|
|||
|
|
@ -682,6 +682,8 @@ struct EditorApp {
|
|||
recording_layer_id: Option<Uuid>, // Layer being recorded to (for creating clips)
|
||||
// Asset drag-and-drop state
|
||||
dragging_asset: Option<panes::DraggingAsset>, // Asset being dragged from Asset Library
|
||||
// Clipboard
|
||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager,
|
||||
// Shader editor inter-pane communication
|
||||
effect_to_load: Option<Uuid>, // Effect ID to load into shader editor (set by asset library)
|
||||
// Effect thumbnail invalidation queue (persists across frames until processed)
|
||||
|
|
@ -896,6 +898,7 @@ impl EditorApp {
|
|||
recording_start_time: 0.0, // Will be set when recording starts
|
||||
recording_layer_id: None, // Will be set when recording starts
|
||||
dragging_asset: None, // No asset being dragged initially
|
||||
clipboard_manager: lightningbeam_core::clipboard::ClipboardManager::new(),
|
||||
effect_to_load: None, // No effect to load initially
|
||||
effect_thumbnails_to_invalidate: Vec::new(), // No thumbnails to invalidate initially
|
||||
last_import_filter: ImportFilter::default(), // Default to "All Supported"
|
||||
|
|
@ -1472,6 +1475,364 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
|
||||
/// Copy the current selection to the clipboard
|
||||
fn clipboard_copy_selection(&mut self) {
|
||||
use lightningbeam_core::clipboard::{ClipboardContent, ClipboardLayerType};
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
// Check what's selected: clip instances take priority, then shapes
|
||||
if !self.selection.clip_instances().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let document = self.action_executor.document();
|
||||
let layer = match document.get_layer(&active_layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let layer_type = ClipboardLayerType::from_layer(layer);
|
||||
|
||||
let instances: Vec<_> = match layer {
|
||||
AnyLayer::Vector(vl) => &vl.clip_instances,
|
||||
AnyLayer::Audio(al) => &al.clip_instances,
|
||||
AnyLayer::Video(vl) => &vl.clip_instances,
|
||||
AnyLayer::Effect(el) => &el.clip_instances,
|
||||
}
|
||||
.iter()
|
||||
.filter(|ci| self.selection.contains_clip_instance(&ci.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if instances.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gather referenced clip definitions
|
||||
let mut audio_clips = Vec::new();
|
||||
let mut video_clips = Vec::new();
|
||||
let mut vector_clips = Vec::new();
|
||||
let image_assets = Vec::new();
|
||||
let mut seen_clip_ids = std::collections::HashSet::new();
|
||||
|
||||
for inst in &instances {
|
||||
if !seen_clip_ids.insert(inst.clip_id) {
|
||||
continue;
|
||||
}
|
||||
if let Some(clip) = document.get_audio_clip(&inst.clip_id) {
|
||||
audio_clips.push((inst.clip_id, clip.clone()));
|
||||
} else if let Some(clip) = document.get_video_clip(&inst.clip_id) {
|
||||
video_clips.push((inst.clip_id, clip.clone()));
|
||||
} else if let Some(clip) = document.get_vector_clip(&inst.clip_id) {
|
||||
vector_clips.push((inst.clip_id, clip.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
// Gather image assets referenced by vector clips
|
||||
// (Future: walk vector clip layers for image fill references)
|
||||
|
||||
let content = ClipboardContent::ClipInstances {
|
||||
layer_type,
|
||||
instances,
|
||||
audio_clips,
|
||||
video_clips,
|
||||
vector_clips,
|
||||
image_assets,
|
||||
};
|
||||
|
||||
self.clipboard_manager.copy(content);
|
||||
} else if !self.selection.shape_instances().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let document = self.action_executor.document();
|
||||
let layer = match document.get_layer(&active_layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Gather selected shape instances and their shape definitions
|
||||
let selected_instances: Vec<_> = vector_layer
|
||||
.shape_instances
|
||||
.iter()
|
||||
.filter(|si| self.selection.contains_shape_instance(&si.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if selected_instances.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut shapes = Vec::new();
|
||||
let mut seen_shape_ids = std::collections::HashSet::new();
|
||||
for inst in &selected_instances {
|
||||
if seen_shape_ids.insert(inst.shape_id) {
|
||||
if let Some(shape) = vector_layer.shapes.get(&inst.shape_id) {
|
||||
shapes.push((inst.shape_id, shape.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content = ClipboardContent::Shapes {
|
||||
shapes,
|
||||
instances: selected_instances,
|
||||
};
|
||||
|
||||
self.clipboard_manager.copy(content);
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the current selection (for cut and delete operations)
|
||||
fn clipboard_delete_selection(&mut self) {
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
if !self.selection.clip_instances().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Build removals list
|
||||
let removals: Vec<(Uuid, Uuid)> = self
|
||||
.selection
|
||||
.clip_instances()
|
||||
.iter()
|
||||
.map(|&id| (active_layer_id, id))
|
||||
.collect();
|
||||
|
||||
if removals.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let action = lightningbeam_core::actions::RemoveClipInstancesAction::new(removals);
|
||||
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
let mut backend_context = lightningbeam_core::action::BackendContext {
|
||||
audio_controller: Some(&mut *controller),
|
||||
layer_to_track_map: &self.layer_to_track_map,
|
||||
clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map,
|
||||
};
|
||||
if let Err(e) = self
|
||||
.action_executor
|
||||
.execute_with_backend(Box::new(action), &mut backend_context)
|
||||
{
|
||||
eprintln!("Delete clip instances failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Delete clip instances failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
self.selection.clear_clip_instances();
|
||||
} else if !self.selection.shape_instances().is_empty() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let document = self.action_executor.document();
|
||||
let layer = match document.get_layer(&active_layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
|
||||
let vector_layer = match layer {
|
||||
AnyLayer::Vector(vl) => vl,
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Collect shape instance IDs and their shape IDs
|
||||
let instance_ids: Vec<Uuid> = self.selection.shape_instances().to_vec();
|
||||
let mut shape_ids: Vec<Uuid> = Vec::new();
|
||||
let mut shape_id_set = std::collections::HashSet::new();
|
||||
|
||||
for inst in &vector_layer.shape_instances {
|
||||
if instance_ids.contains(&inst.id) {
|
||||
if shape_id_set.insert(inst.shape_id) {
|
||||
// Only remove shape definition if no other instances reference it
|
||||
let other_refs = vector_layer
|
||||
.shape_instances
|
||||
.iter()
|
||||
.any(|si| si.shape_id == inst.shape_id && !instance_ids.contains(&si.id));
|
||||
if !other_refs {
|
||||
shape_ids.push(inst.shape_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let action = lightningbeam_core::actions::RemoveShapesAction::new(
|
||||
active_layer_id,
|
||||
shape_ids,
|
||||
instance_ids,
|
||||
);
|
||||
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Delete shapes failed: {}", e);
|
||||
}
|
||||
|
||||
self.selection.clear_shape_instances();
|
||||
self.selection.clear_shapes();
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste from clipboard
|
||||
fn clipboard_paste(&mut self) {
|
||||
use lightningbeam_core::clipboard::ClipboardContent;
|
||||
use lightningbeam_core::layer::AnyLayer;
|
||||
|
||||
let content = match self.clipboard_manager.paste() {
|
||||
Some(c) => c,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Regenerate IDs for the paste
|
||||
let (new_content, _id_map) = content.with_regenerated_ids();
|
||||
|
||||
match new_content {
|
||||
ClipboardContent::ClipInstances {
|
||||
layer_type,
|
||||
mut instances,
|
||||
audio_clips,
|
||||
video_clips,
|
||||
vector_clips,
|
||||
image_assets,
|
||||
} => {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Verify layer compatibility
|
||||
{
|
||||
let document = self.action_executor.document();
|
||||
let layer = match document.get_layer(&active_layer_id) {
|
||||
Some(l) => l,
|
||||
None => return,
|
||||
};
|
||||
if !layer_type.is_compatible(layer) {
|
||||
eprintln!("Cannot paste: incompatible layer type");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add clip definitions to document (they have new IDs from regeneration)
|
||||
{
|
||||
let document = self.action_executor.document_mut();
|
||||
for (_id, clip) in &audio_clips {
|
||||
document.audio_clips.insert(clip.id, clip.clone());
|
||||
}
|
||||
for (_id, clip) in &video_clips {
|
||||
document.video_clips.insert(clip.id, clip.clone());
|
||||
}
|
||||
for (_id, clip) in &vector_clips {
|
||||
document.vector_clips.insert(clip.id, clip.clone());
|
||||
}
|
||||
for (_id, asset) in &image_assets {
|
||||
document.image_assets.insert(asset.id, asset.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Position instances at playhead, preserving relative offsets
|
||||
if !instances.is_empty() {
|
||||
let min_start = instances
|
||||
.iter()
|
||||
.map(|i| i.timeline_start)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let offset = self.playback_time - min_start;
|
||||
for inst in &mut instances {
|
||||
inst.timeline_start = (inst.timeline_start + offset).max(0.0);
|
||||
}
|
||||
}
|
||||
|
||||
// Add each instance via action (handles overlap avoidance)
|
||||
let new_ids: Vec<Uuid> = instances.iter().map(|i| i.id).collect();
|
||||
|
||||
for instance in instances {
|
||||
let action = lightningbeam_core::actions::AddClipInstanceAction::new(
|
||||
active_layer_id,
|
||||
instance,
|
||||
);
|
||||
|
||||
if let Some(ref controller_arc) = self.audio_controller {
|
||||
let mut controller = controller_arc.lock().unwrap();
|
||||
let mut backend_context = lightningbeam_core::action::BackendContext {
|
||||
audio_controller: Some(&mut *controller),
|
||||
layer_to_track_map: &self.layer_to_track_map,
|
||||
clip_instance_to_backend_map: &mut self.clip_instance_to_backend_map,
|
||||
};
|
||||
if let Err(e) = self
|
||||
.action_executor
|
||||
.execute_with_backend(Box::new(action), &mut backend_context)
|
||||
{
|
||||
eprintln!("Paste clip failed: {}", e);
|
||||
}
|
||||
} else {
|
||||
if let Err(e) = self.action_executor.execute(Box::new(action)) {
|
||||
eprintln!("Paste clip failed: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Select pasted clips
|
||||
self.selection.clear_clip_instances();
|
||||
for id in new_ids {
|
||||
self.selection.add_clip_instance(id);
|
||||
}
|
||||
}
|
||||
ClipboardContent::Shapes {
|
||||
shapes,
|
||||
instances,
|
||||
} => {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
None => return,
|
||||
};
|
||||
|
||||
// Add shapes and instances to the active vector layer
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
let new_instance_ids: Vec<Uuid> = instances.iter().map(|i| i.id).collect();
|
||||
|
||||
for (id, shape) in shapes {
|
||||
vector_layer.shapes.insert(id, shape);
|
||||
}
|
||||
for inst in instances {
|
||||
vector_layer.shape_instances.push(inst);
|
||||
}
|
||||
|
||||
// Select pasted shapes
|
||||
self.selection.clear_shape_instances();
|
||||
for id in new_instance_ids {
|
||||
self.selection.add_shape_instance(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Duplicate the selected clip instances on the active layer.
|
||||
/// Each duplicate is placed immediately after the original clip.
|
||||
fn duplicate_selected_clips(&mut self) {
|
||||
|
|
@ -1870,20 +2231,17 @@ impl EditorApp {
|
|||
}
|
||||
}
|
||||
MenuAction::Cut => {
|
||||
println!("Menu: Cut");
|
||||
// TODO: Implement cut
|
||||
self.clipboard_copy_selection();
|
||||
self.clipboard_delete_selection();
|
||||
}
|
||||
MenuAction::Copy => {
|
||||
println!("Menu: Copy");
|
||||
// TODO: Implement copy
|
||||
self.clipboard_copy_selection();
|
||||
}
|
||||
MenuAction::Paste => {
|
||||
println!("Menu: Paste");
|
||||
// TODO: Implement paste
|
||||
self.clipboard_paste();
|
||||
}
|
||||
MenuAction::Delete => {
|
||||
println!("Menu: Delete");
|
||||
// TODO: Implement delete
|
||||
self.clipboard_delete_selection();
|
||||
}
|
||||
MenuAction::SelectAll => {
|
||||
println!("Menu: Select All");
|
||||
|
|
@ -4030,6 +4388,7 @@ impl eframe::App for EditorApp {
|
|||
effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate,
|
||||
target_format: self.target_format,
|
||||
pending_menu_actions: &mut pending_menu_actions,
|
||||
clipboard_manager: &mut self.clipboard_manager,
|
||||
};
|
||||
|
||||
render_layout_node(
|
||||
|
|
@ -4153,6 +4512,24 @@ impl eframe::App for EditorApp {
|
|||
}
|
||||
|
||||
ctx.input(|i| {
|
||||
// Handle clipboard events (Ctrl+C/X/V) — winit converts these to
|
||||
// Event::Copy/Cut/Paste instead of regular key events, so
|
||||
// check_shortcuts won't see them via key_pressed().
|
||||
for event in &i.events {
|
||||
match event {
|
||||
egui::Event::Copy => {
|
||||
self.handle_menu_action(MenuAction::Copy);
|
||||
}
|
||||
egui::Event::Cut => {
|
||||
self.handle_menu_action(MenuAction::Cut);
|
||||
}
|
||||
egui::Event::Paste(_) => {
|
||||
self.handle_menu_action(MenuAction::Paste);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
if let Some(action) = MenuSystem::check_shortcuts(i) {
|
||||
|
|
@ -4282,6 +4659,8 @@ struct RenderContext<'a> {
|
|||
target_format: wgpu::TextureFormat,
|
||||
/// Menu actions queued by panes (e.g. context menus), processed after rendering
|
||||
pending_menu_actions: &'a mut Vec<MenuAction>,
|
||||
/// Clipboard manager for paste availability checks
|
||||
clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager,
|
||||
}
|
||||
|
||||
/// Recursively render a layout node with drag support
|
||||
|
|
@ -4761,6 +5140,7 @@ fn render_pane(
|
|||
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
|
||||
target_format: ctx.target_format,
|
||||
pending_menu_actions: ctx.pending_menu_actions,
|
||||
clipboard_manager: ctx.clipboard_manager,
|
||||
};
|
||||
pane_instance.render_header(&mut header_ui, &mut shared);
|
||||
}
|
||||
|
|
@ -4829,6 +5209,7 @@ fn render_pane(
|
|||
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
|
||||
target_format: ctx.target_format,
|
||||
pending_menu_actions: ctx.pending_menu_actions,
|
||||
clipboard_manager: ctx.clipboard_manager,
|
||||
};
|
||||
|
||||
// Render pane content (header was already rendered above)
|
||||
|
|
|
|||
|
|
@ -213,6 +213,8 @@ pub struct SharedPaneState<'a> {
|
|||
pub target_format: wgpu::TextureFormat,
|
||||
/// Menu actions queued by panes (e.g. context menu items), processed by main after rendering
|
||||
pub pending_menu_actions: &'a mut Vec<crate::menu::MenuAction>,
|
||||
/// Clipboard manager for cut/copy/paste operations
|
||||
pub clipboard_manager: &'a mut lightningbeam_core::clipboard::ClipboardManager,
|
||||
}
|
||||
|
||||
/// Trait for pane rendering
|
||||
|
|
|
|||
|
|
@ -56,8 +56,9 @@ pub struct TimelinePane {
|
|||
/// Track if a layer control widget was clicked this frame
|
||||
layer_control_clicked: bool,
|
||||
|
||||
/// Context menu state: Some((clip_instance_id, position)) when a right-click menu is open
|
||||
context_menu_clip: Option<(uuid::Uuid, egui::Pos2)>,
|
||||
/// Context menu state: Some((optional_clip_instance_id, position)) when a right-click menu is open
|
||||
/// clip_id is None when right-clicking on empty timeline space
|
||||
context_menu_clip: Option<(Option<uuid::Uuid>, egui::Pos2)>,
|
||||
}
|
||||
|
||||
/// Check if a clip type can be dropped on a layer type
|
||||
|
|
@ -2179,32 +2180,36 @@ impl PaneRenderer for TimelinePane {
|
|||
shared.audio_controller,
|
||||
);
|
||||
|
||||
// Clip context menu: detect right-click on clips
|
||||
// Context menu: detect right-click on clips or empty timeline space
|
||||
let mut just_opened_menu = false;
|
||||
let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
|
||||
if secondary_clicked {
|
||||
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
||||
if content_rect.contains(pos) {
|
||||
if let Some((_drag_type, clip_id)) = self.detect_clip_at_pointer(pos, document, content_rect, layer_headers_rect) {
|
||||
// Select the clip if not already selected
|
||||
// Right-clicked on a clip
|
||||
if !shared.selection.contains_clip_instance(&clip_id) {
|
||||
shared.selection.select_only_clip_instance(clip_id);
|
||||
}
|
||||
self.context_menu_clip = Some((clip_id, pos));
|
||||
just_opened_menu = true;
|
||||
self.context_menu_clip = Some((Some(clip_id), pos));
|
||||
} else {
|
||||
self.context_menu_clip = None;
|
||||
// Right-clicked on empty timeline space
|
||||
self.context_menu_clip = Some((None, pos));
|
||||
}
|
||||
just_opened_menu = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render clip context menu
|
||||
if let Some((_ctx_clip_id, menu_pos)) = self.context_menu_clip {
|
||||
// Render context menu
|
||||
if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip {
|
||||
let has_clip = ctx_clip_id.is_some();
|
||||
// Determine which items are enabled
|
||||
let playback_time = *shared.playback_time;
|
||||
let min_split_px = 4.0_f32;
|
||||
|
||||
// Split: playhead must be over a selected clip, at least min_split_px from edges
|
||||
let split_enabled = {
|
||||
let split_enabled = has_clip && {
|
||||
let mut enabled = false;
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
|
|
@ -2233,7 +2238,7 @@ impl PaneRenderer for TimelinePane {
|
|||
};
|
||||
|
||||
// Duplicate: check if there's room to the right of each selected clip
|
||||
let duplicate_enabled = {
|
||||
let duplicate_enabled = has_clip && {
|
||||
let mut enabled = false;
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
|
|
@ -2263,6 +2268,58 @@ impl PaneRenderer for TimelinePane {
|
|||
enabled
|
||||
};
|
||||
|
||||
// Paste: check if clipboard has content and there's room at playhead
|
||||
let paste_enabled = {
|
||||
let mut enabled = false;
|
||||
if shared.clipboard_manager.has_content() {
|
||||
if let Some(layer_id) = *shared.active_layer_id {
|
||||
if let Some(content) = shared.clipboard_manager.paste() {
|
||||
if let lightningbeam_core::clipboard::ClipboardContent::ClipInstances {
|
||||
ref layer_type,
|
||||
ref instances,
|
||||
..
|
||||
} = content
|
||||
{
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
if layer_type.is_compatible(layer) && !instances.is_empty() {
|
||||
// Check if each pasted clip would fit at playhead
|
||||
let min_start = instances
|
||||
.iter()
|
||||
.map(|i| i.timeline_start)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let offset = *shared.playback_time - min_start;
|
||||
|
||||
enabled = instances.iter().all(|ci| {
|
||||
let paste_start = (ci.timeline_start + offset).max(0.0);
|
||||
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
|
||||
let eff = ci.effective_duration(dur);
|
||||
document
|
||||
.find_nearest_valid_position(
|
||||
&layer_id,
|
||||
paste_start,
|
||||
eff,
|
||||
&[],
|
||||
)
|
||||
.is_some()
|
||||
} else {
|
||||
// Clip def not in document yet (from external paste) — allow
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Shapes paste — always enabled if layer is vector
|
||||
if let Some(layer) = document.get_layer(&layer_id) {
|
||||
enabled = matches!(layer, AnyLayer::Vector(_));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
enabled
|
||||
};
|
||||
|
||||
let area_id = ui.id().with("clip_context_menu");
|
||||
let mut item_clicked = false;
|
||||
let area_response = egui::Area::new(area_id)
|
||||
|
|
@ -2311,16 +2368,20 @@ impl PaneRenderer for TimelinePane {
|
|||
item_clicked = true;
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Cut", true) {
|
||||
if menu_item(ui, "Cut", has_clip) {
|
||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Cut);
|
||||
item_clicked = true;
|
||||
}
|
||||
if menu_item(ui, "Copy", true) {
|
||||
if menu_item(ui, "Copy", has_clip) {
|
||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Copy);
|
||||
item_clicked = true;
|
||||
}
|
||||
if menu_item(ui, "Paste", paste_enabled) {
|
||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Paste);
|
||||
item_clicked = true;
|
||||
}
|
||||
ui.separator();
|
||||
if menu_item(ui, "Delete", true) {
|
||||
if menu_item(ui, "Delete", has_clip) {
|
||||
shared.pending_menu_actions.push(crate::menu::MenuAction::Delete);
|
||||
item_clicked = true;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue