Add copy and paste

This commit is contained in:
Skyler Lehmkuhl 2026-02-15 04:38:42 -05:00
parent 5164d7a0a9
commit c6a8b944e5
17 changed files with 1298 additions and 74 deletions

View File

@ -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,
}
}

View File

@ -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) => {

View File

@ -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) {

View File

@ -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;

View File

@ -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() {

View File

@ -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;

View File

@ -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"

View File

@ -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)) => {

View File

@ -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;

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}

View File

@ -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)? {

View File

@ -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
}
}

View File

@ -40,3 +40,4 @@ pub mod planar_graph;
pub mod file_types;
pub mod file_io;
pub mod export;
pub mod clipboard;

View File

@ -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)

View File

@ -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

View File

@ -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;
}