Add right click menu to clips

This commit is contained in:
Skyler Lehmkuhl 2026-02-15 02:45:53 -05:00
parent 394e369122
commit 5164d7a0a9
3 changed files with 187 additions and 0 deletions

View File

@ -1518,6 +1518,8 @@ impl EditorApp {
duplicate duplicate
}).collect(); }).collect();
let new_ids: Vec<uuid::Uuid> = duplicates.iter().map(|d| d.id).collect();
for duplicate in duplicates { for duplicate in duplicates {
let action = AddClipInstanceAction::new(active_layer_id, duplicate); let action = AddClipInstanceAction::new(active_layer_id, duplicate);
@ -1537,6 +1539,12 @@ impl EditorApp {
} }
} }
} }
// Select the new duplicates instead of the originals
self.selection.clear_clip_instances();
for id in new_ids {
self.selection.add_clip_instance(id);
}
} }
fn switch_layout(&mut self, index: usize) { fn switch_layout(&mut self, index: usize) {
@ -3967,6 +3975,9 @@ impl eframe::App for EditorApp {
// Registry for actions to execute after rendering (two-phase dispatch) // Registry for actions to execute after rendering (two-phase dispatch)
let mut pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>> = Vec::new(); let mut pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>> = Vec::new();
// Menu actions queued by pane context menus
let mut pending_menu_actions: Vec<MenuAction> = Vec::new();
// Queue for effect thumbnail requests (collected during rendering) // Queue for effect thumbnail requests (collected during rendering)
let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new(); let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new();
// Empty cache fallback if generator not initialized // Empty cache fallback if generator not initialized
@ -4018,6 +4029,7 @@ impl eframe::App for EditorApp {
.unwrap_or(&empty_thumbnail_cache), .unwrap_or(&empty_thumbnail_cache),
effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate, effect_thumbnails_to_invalidate: &mut self.effect_thumbnails_to_invalidate,
target_format: self.target_format, target_format: self.target_format,
pending_menu_actions: &mut pending_menu_actions,
}; };
render_layout_node( render_layout_node(
@ -4084,6 +4096,11 @@ impl eframe::App for EditorApp {
} }
} }
// Process menu actions queued by pane context menus
for action in pending_menu_actions {
self.handle_menu_action(action);
}
// Set cursor based on hover state // Set cursor based on hover state
if let Some((_, is_horizontal)) = self.hovered_divider { if let Some((_, is_horizontal)) = self.hovered_divider {
if is_horizontal { if is_horizontal {
@ -4263,6 +4280,8 @@ struct RenderContext<'a> {
effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>, effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
/// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform) /// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform)
target_format: wgpu::TextureFormat, target_format: wgpu::TextureFormat,
/// Menu actions queued by panes (e.g. context menus), processed after rendering
pending_menu_actions: &'a mut Vec<MenuAction>,
} }
/// Recursively render a layout node with drag support /// Recursively render a layout node with drag support
@ -4741,6 +4760,7 @@ fn render_pane(
effect_thumbnail_cache: ctx.effect_thumbnail_cache, effect_thumbnail_cache: ctx.effect_thumbnail_cache,
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
target_format: ctx.target_format, target_format: ctx.target_format,
pending_menu_actions: ctx.pending_menu_actions,
}; };
pane_instance.render_header(&mut header_ui, &mut shared); pane_instance.render_header(&mut header_ui, &mut shared);
} }
@ -4808,6 +4828,7 @@ fn render_pane(
effect_thumbnail_cache: ctx.effect_thumbnail_cache, effect_thumbnail_cache: ctx.effect_thumbnail_cache,
effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate, effect_thumbnails_to_invalidate: ctx.effect_thumbnails_to_invalidate,
target_format: ctx.target_format, target_format: ctx.target_format,
pending_menu_actions: ctx.pending_menu_actions,
}; };
// Render pane content (header was already rendered above) // Render pane content (header was already rendered above)

View File

@ -211,6 +211,8 @@ pub struct SharedPaneState<'a> {
pub effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>, pub effect_thumbnails_to_invalidate: &'a mut Vec<Uuid>,
/// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform) /// Surface texture format for GPU rendering (Rgba8Unorm or Bgra8Unorm depending on platform)
pub target_format: wgpu::TextureFormat, 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>,
} }
/// Trait for pane rendering /// Trait for pane rendering

View File

@ -55,6 +55,9 @@ pub struct TimelinePane {
/// Track if a layer control widget was clicked this frame /// Track if a layer control widget was clicked this frame
layer_control_clicked: bool, 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)>,
} }
/// Check if a clip type can be dropped on a layer type /// Check if a clip type can be dropped on a layer type
@ -120,6 +123,7 @@ impl TimelinePane {
drag_offset: 0.0, drag_offset: 0.0,
mousedown_pos: None, mousedown_pos: None,
layer_control_clicked: false, layer_control_clicked: false,
context_menu_clip: None,
} }
} }
@ -2175,6 +2179,166 @@ impl PaneRenderer for TimelinePane {
shared.audio_controller, shared.audio_controller,
); );
// Clip context menu: detect right-click on clips
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 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
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;
} else {
self.context_menu_clip = None;
}
}
}
// Render clip context menu
if let Some((_ctx_clip_id, menu_pos)) = self.context_menu_clip {
// 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 mut enabled = false;
if let Some(layer_id) = *shared.active_layer_id {
if let Some(layer) = document.get_layer(&layer_id) {
let instances: &[ClipInstance] = 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,
};
for inst in instances {
if !shared.selection.contains_clip_instance(&inst.id) { continue; }
if let Some(dur) = document.get_clip_duration(&inst.clip_id) {
let eff = inst.effective_duration(dur);
let start = inst.timeline_start;
let end = start + eff;
let min_dist = min_split_px as f64 / self.pixels_per_second as f64;
if playback_time > start + min_dist && playback_time < end - min_dist {
enabled = true;
break;
}
}
}
}
}
enabled
};
// Duplicate: check if there's room to the right of each selected clip
let duplicate_enabled = {
let mut enabled = false;
if let Some(layer_id) = *shared.active_layer_id {
if let Some(layer) = document.get_layer(&layer_id) {
let instances: &[ClipInstance] = 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,
};
// Check each selected clip
enabled = instances.iter()
.filter(|ci| shared.selection.contains_clip_instance(&ci.id))
.all(|ci| {
if let Some(dur) = document.get_clip_duration(&ci.clip_id) {
let eff = ci.effective_duration(dur);
let max_extend = document.find_max_trim_extend_right(
&layer_id, &ci.id, ci.timeline_start, eff,
);
max_extend >= eff
} else {
false
}
})
&& instances.iter().any(|ci| shared.selection.contains_clip_instance(&ci.id));
}
}
enabled
};
let area_id = ui.id().with("clip_context_menu");
let mut item_clicked = false;
let area_response = egui::Area::new(area_id)
.order(egui::Order::Foreground)
.fixed_pos(menu_pos)
.interactable(true)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(160.0);
// Helper: full-width menu item with optional enabled state
let menu_item = |ui: &mut egui::Ui, label: &str, enabled: bool| -> bool {
let desired_width = ui.available_width();
let (rect, response) = ui.allocate_exact_size(
egui::vec2(desired_width, ui.spacing().interact_size.y),
if enabled { egui::Sense::click() } else { egui::Sense::hover() },
);
if ui.is_rect_visible(rect) {
if enabled && response.hovered() {
ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill);
}
let text_color = if !enabled {
ui.visuals().weak_text_color()
} else if response.hovered() {
ui.visuals().widgets.hovered.text_color()
} else {
ui.visuals().widgets.inactive.text_color()
};
ui.painter().text(
rect.min + egui::vec2(4.0, (rect.height() - 14.0) / 2.0),
egui::Align2::LEFT_TOP,
label,
egui::FontId::proportional(14.0),
text_color,
);
}
enabled && response.clicked()
};
if menu_item(ui, "Split Clip", split_enabled) {
shared.pending_menu_actions.push(crate::menu::MenuAction::SplitClip);
item_clicked = true;
}
if menu_item(ui, "Duplicate Clip", duplicate_enabled) {
shared.pending_menu_actions.push(crate::menu::MenuAction::DuplicateClip);
item_clicked = true;
}
ui.separator();
if menu_item(ui, "Cut", true) {
shared.pending_menu_actions.push(crate::menu::MenuAction::Cut);
item_clicked = true;
}
if menu_item(ui, "Copy", true) {
shared.pending_menu_actions.push(crate::menu::MenuAction::Copy);
item_clicked = true;
}
ui.separator();
if menu_item(ui, "Delete", true) {
shared.pending_menu_actions.push(crate::menu::MenuAction::Delete);
item_clicked = true;
}
});
});
// Close on item click or click outside (skip on the frame we just opened)
if !just_opened_menu {
let any_click = ui.input(|i| {
i.pointer.button_clicked(egui::PointerButton::Primary)
|| i.pointer.button_clicked(egui::PointerButton::Secondary)
});
if item_clicked || (any_click && !area_response.response.contains_pointer()) {
self.context_menu_clip = None;
}
}
}
// VIDEO HOVER DETECTION: Handle video clip hover tooltips AFTER input handling // VIDEO HOVER DETECTION: Handle video clip hover tooltips AFTER input handling
// This ensures hover events aren't consumed by the main input handler // This ensures hover events aren't consumed by the main input handler
for (clip_rect, clip_id, trim_start, instance_start) in video_clip_hovers { for (clip_rect, clip_id, trim_start, instance_start) in video_clip_hovers {