Add right click menu to clips
This commit is contained in:
parent
394e369122
commit
5164d7a0a9
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue