6403 lines
334 KiB
Rust
6403 lines
334 KiB
Rust
/// Timeline pane - Modern GarageBand-style timeline
|
||
///
|
||
/// Phase 1 Implementation: Time Ruler & Playhead
|
||
/// - Time-based ruler (seconds, not frames)
|
||
/// - Playhead for current time
|
||
/// - Zoom/pan controls
|
||
/// - Basic layer visualization
|
||
|
||
use eframe::egui;
|
||
use lightningbeam_core::clip::{
|
||
ClipInstance, audio_backend_uuid, midi_backend_uuid,
|
||
};
|
||
use lightningbeam_core::layer::{AnyLayer, AudioLayerType, GroupLayer, LayerTrait};
|
||
use super::{DragClipType, NodePath, PaneRenderer, SharedPaneState};
|
||
use std::collections::HashMap;
|
||
|
||
const RULER_HEIGHT: f32 = 30.0;
|
||
const LAYER_HEIGHT: f32 = 60.0;
|
||
const LAYER_HEADER_WIDTH: f32 = 200.0;
|
||
const MIN_PIXELS_PER_SECOND: f32 = 1.0; // Allow zooming out to see 10+ minutes
|
||
const MAX_PIXELS_PER_SECOND: f32 = 500.0;
|
||
const EDGE_DETECTION_PIXELS: f32 = 8.0; // Distance from edge to detect trim handles
|
||
const LOOP_CORNER_SIZE: f32 = 12.0; // Size of loop corner hotzone at top-right of clip
|
||
const MIN_CLIP_WIDTH_PX: f32 = 8.0; // Minimum visible width for very short clips (e.g. groups)
|
||
const AUTOMATION_LANE_HEIGHT: f32 = 40.0;
|
||
|
||
/// Compute stacking row assignments for clip instances on a vector layer.
|
||
/// Only clips that overlap in time are stacked; non-overlapping clips share row 0.
|
||
/// Returns a Vec of (row, total_rows) for each clip instance.
|
||
fn compute_clip_stacking_from_ranges(
|
||
ranges: &[(f64, f64)],
|
||
) -> Vec<(usize, usize)> {
|
||
if ranges.len() <= 1 {
|
||
return vec![(0, 1); ranges.len()];
|
||
}
|
||
|
||
// Greedy row assignment: assign each clip to the first row where it doesn't overlap
|
||
let mut row_assignments = vec![0usize; ranges.len()];
|
||
let mut row_ends: Vec<f64> = Vec::new(); // track the end time of the last clip in each row
|
||
|
||
// Sort indices by start time for greedy packing
|
||
let mut sorted_indices: Vec<usize> = (0..ranges.len()).collect();
|
||
sorted_indices.sort_by(|&a, &b| ranges[a].0.partial_cmp(&ranges[b].0).unwrap_or(std::cmp::Ordering::Equal));
|
||
|
||
for &idx in &sorted_indices {
|
||
let (start, end) = ranges[idx];
|
||
// Find first row where this clip fits (no overlap)
|
||
let mut assigned_row = None;
|
||
for (row, row_end) in row_ends.iter_mut().enumerate() {
|
||
if start >= *row_end {
|
||
*row_end = end;
|
||
assigned_row = Some(row);
|
||
break;
|
||
}
|
||
}
|
||
if let Some(row) = assigned_row {
|
||
row_assignments[idx] = row;
|
||
} else {
|
||
row_assignments[idx] = row_ends.len();
|
||
row_ends.push(end);
|
||
}
|
||
}
|
||
|
||
let total_rows = row_ends.len().max(1);
|
||
row_assignments.iter().map(|&row| (row, total_rows)).collect()
|
||
}
|
||
|
||
fn compute_clip_stacking(
|
||
document: &lightningbeam_core::document::Document,
|
||
layer: &AnyLayer,
|
||
clip_instances: &[lightningbeam_core::clip::ClipInstance],
|
||
) -> Vec<(usize, usize)> {
|
||
if !matches!(layer, AnyLayer::Vector(_)) || clip_instances.len() <= 1 {
|
||
return vec![(0, 1); clip_instances.len()];
|
||
}
|
||
|
||
let tempo_map = document.tempo_map();
|
||
let ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
|
||
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0);
|
||
let start = ci.effective_start();
|
||
let end = start + ci.total_duration(clip_dur, tempo_map);
|
||
(start, end)
|
||
}).collect();
|
||
|
||
compute_clip_stacking_from_ranges(&ranges)
|
||
}
|
||
|
||
/// Calculate vertical bounds for a clip instance within a layer row.
|
||
/// `row` is the stacking row (0-based), `total_rows` is the total number of rows needed.
|
||
/// Returns (y_min, y_max) relative to the layer top.
|
||
fn clip_instance_y_bounds(row: usize, total_rows: usize) -> (f32, f32) {
|
||
if total_rows > 1 {
|
||
let usable_height = LAYER_HEIGHT - 20.0; // 10px padding top/bottom
|
||
let row_height = (usable_height / total_rows as f32).min(20.0);
|
||
let top = 10.0 + row as f32 * row_height;
|
||
(top, top + row_height - 1.0)
|
||
} else {
|
||
(10.0, LAYER_HEIGHT - 10.0)
|
||
}
|
||
}
|
||
|
||
/// Draw the strip of video thumbnails across a clip.
|
||
///
|
||
/// Thumbnails are tiled from the clip's **true** (unclamped) screen origin
|
||
/// `clip_origin_x` over its full width `clip_true_width_px`, and only the tiles
|
||
/// intersecting `visible_rect` are drawn. This makes the strip scroll naturally
|
||
/// with the timeline and show the correct content when the clip is partly off the
|
||
/// left edge (a tile's content time is derived from its offset from the true
|
||
/// origin, not from the clamped visible edge).
|
||
///
|
||
/// Each tile's GPU texture is cached by the **actual** thumbnail timestamp (from
|
||
/// [`VideoManager::get_thumbnail_at`]), so as closer thumbnails finish generating
|
||
/// the strip refreshes instead of freezing on whatever loaded first.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn draw_video_thumbnail_strip(
|
||
painter: &egui::Painter,
|
||
ui: &egui::Ui,
|
||
video_mgr: &lightningbeam_core::video::VideoManager,
|
||
textures: &mut std::collections::HashMap<(uuid::Uuid, i64), egui::TextureHandle>,
|
||
clip_id: uuid::Uuid,
|
||
trim_start: f64,
|
||
clip_origin_x: f32,
|
||
clip_true_width_px: f32,
|
||
visible_rect: egui::Rect,
|
||
thumb_top_y: f32,
|
||
thumb_height: f32,
|
||
pixels_per_second: f64,
|
||
) {
|
||
if clip_true_width_px <= 0.0 || thumb_height <= 0.0 {
|
||
return;
|
||
}
|
||
// Aspect ratio from any available thumbnail; sets the per-tile width.
|
||
let aspect = match video_mgr.get_thumbnail_at(&clip_id, 0.0) {
|
||
Some((_, tw, th, _)) if th > 0 => tw as f32 / th as f32,
|
||
_ => return,
|
||
};
|
||
let step = (thumb_height * aspect).max(1.0);
|
||
let num_thumbs = ((clip_true_width_px / step).ceil() as usize).max(1);
|
||
|
||
// Iterate only the tiles that intersect the visible rect.
|
||
let first = (((visible_rect.min.x - clip_origin_x) / step).floor() as i64).max(0) as usize;
|
||
let last = (((visible_rect.max.x - clip_origin_x) / step).ceil() as i64).max(0) as usize;
|
||
let last = last.min(num_thumbs);
|
||
|
||
for i in first..last {
|
||
let thumb_x = clip_origin_x + i as f32 * step;
|
||
// Content time at the tile centre, measured from the TRUE clip origin so
|
||
// it's stable as the clip scrolls.
|
||
let content_time =
|
||
trim_start + (i as f64 * step as f64 + step as f64 * 0.5) / pixels_per_second;
|
||
|
||
let Some((actual_ts, tw, th, rgba_data)) = video_mgr.get_thumbnail_at(&clip_id, content_time)
|
||
else {
|
||
continue;
|
||
};
|
||
let ts_key = (actual_ts * 1000.0) as i64;
|
||
let texture = textures.entry((clip_id, ts_key)).or_insert_with(|| {
|
||
let image = egui::ColorImage::from_rgba_unmultiplied([tw as usize, th as usize], &rgba_data);
|
||
ui.ctx().load_texture(
|
||
format!("vthumb_{}_{}", clip_id, ts_key),
|
||
image,
|
||
egui::TextureOptions::LINEAR,
|
||
)
|
||
});
|
||
|
||
let full_rect = egui::Rect::from_min_size(
|
||
egui::pos2(thumb_x, thumb_top_y),
|
||
egui::vec2(step, thumb_height),
|
||
);
|
||
let thumb_rect = full_rect.intersect(visible_rect);
|
||
if thumb_rect.width() > 2.0 && thumb_rect.height() > 2.0 {
|
||
let uv_min = egui::pos2(
|
||
(thumb_rect.min.x - full_rect.min.x) / full_rect.width(),
|
||
(thumb_rect.min.y - full_rect.min.y) / full_rect.height(),
|
||
);
|
||
let uv_max = egui::pos2(
|
||
(thumb_rect.max.x - full_rect.min.x) / full_rect.width(),
|
||
(thumb_rect.max.y - full_rect.min.y) / full_rect.height(),
|
||
);
|
||
painter.image(
|
||
texture.id(),
|
||
thumb_rect,
|
||
egui::Rect::from_min_max(uv_min, uv_max),
|
||
egui::Color32::WHITE,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Get the effective clip duration for a clip instance on a given layer.
|
||
/// For groups on vector layers, the duration spans all consecutive keyframes
|
||
/// where the group is present. For regular clips, returns the clip's internal duration.
|
||
fn effective_clip_duration(
|
||
document: &lightningbeam_core::document::Document,
|
||
layer: &AnyLayer,
|
||
clip_instance: &ClipInstance,
|
||
) -> Option<f64> {
|
||
match layer {
|
||
AnyLayer::Vector(vl) => {
|
||
let vc = document.get_vector_clip(&clip_instance.clip_id)?;
|
||
if vc.is_group {
|
||
let frame_duration = 1.0 / document.framerate;
|
||
let end = vl.group_visibility_end(&clip_instance.id, clip_instance.timeline_start, frame_duration);
|
||
Some((end - clip_instance.timeline_start).max(0.0))
|
||
} else {
|
||
// Movie clips: duration based on all internal content (keyframes + clip instances)
|
||
document.get_clip_duration(&clip_instance.clip_id)
|
||
}
|
||
}
|
||
AnyLayer::Audio(_) => document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration),
|
||
AnyLayer::Video(_) => document.get_video_clip(&clip_instance.clip_id).map(|c| c.duration),
|
||
AnyLayer::Effect(_) => Some(lightningbeam_core::effect::EFFECT_DURATION),
|
||
AnyLayer::Group(_) => None,
|
||
AnyLayer::Raster(_) => None,
|
||
}
|
||
}
|
||
|
||
/// Type of clip drag operation
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
enum ClipDragType {
|
||
Move,
|
||
TrimLeft,
|
||
TrimRight,
|
||
LoopExtendRight,
|
||
LoopExtendLeft,
|
||
}
|
||
|
||
use lightningbeam_core::document::TimelineMode;
|
||
|
||
/// State for an in-progress layer header drag-to-reorder operation.
|
||
struct LayerDragState {
|
||
/// IDs of the layers being dragged (in visual order, top to bottom)
|
||
layer_ids: Vec<uuid::Uuid>,
|
||
/// Original parent group IDs for each dragged layer (parallel to layer_ids)
|
||
source_parent_ids: Vec<Option<uuid::Uuid>>,
|
||
/// Current gap position in the filtered (dragged-layers-removed) row list
|
||
gap_row_index: usize,
|
||
/// Current mouse Y in screen coordinates (for floating header rendering)
|
||
current_mouse_y: f32,
|
||
/// Y offset from the top of the topmost dragged row to the mousedown point
|
||
grab_offset_y: f32,
|
||
}
|
||
|
||
pub struct TimelinePane {
|
||
/// Horizontal zoom level (pixels per second)
|
||
pixels_per_second: f32,
|
||
|
||
/// Horizontal scroll offset (in seconds)
|
||
viewport_start_time: f64,
|
||
|
||
/// Vertical scroll offset (in pixels)
|
||
viewport_scroll_y: f32,
|
||
|
||
/// Clickable keyframe-diamond hit targets `(screen_rect, keyframe_time)` collected
|
||
/// during the last `render_layers`. Used by `handle_input` (next frame) to snap the
|
||
/// playhead exactly to a keyframe when its diamond is clicked.
|
||
keyframe_diamond_hits: Vec<(egui::Rect, f64)>,
|
||
|
||
/// Total duration of the animation
|
||
duration: f64,
|
||
|
||
/// Is the user currently dragging the playhead?
|
||
is_scrubbing: bool,
|
||
|
||
/// Is the user panning the timeline?
|
||
is_panning: bool,
|
||
last_pan_pos: Option<egui::Pos2>,
|
||
|
||
/// Clip drag state (None if not dragging)
|
||
clip_drag_state: Option<ClipDragType>,
|
||
drag_offset: f64, // Time offset being applied during drag (for preview)
|
||
drag_anchor_start: f64, // Original timeline_start of earliest selected clip; used for snapped move offset
|
||
|
||
/// Cached mouse position from mousedown (used for edge detection when drag starts)
|
||
mousedown_pos: Option<egui::Pos2>,
|
||
|
||
/// Track if a layer control widget was clicked this frame
|
||
layer_control_clicked: bool,
|
||
|
||
/// 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)>,
|
||
|
||
/// Whether to display time as seconds or measures
|
||
time_display_format: TimelineMode,
|
||
|
||
/// Waveform upload progress: pool_index -> frames uploaded so far.
|
||
/// Tracks chunked GPU uploads across frames to avoid hitches.
|
||
waveform_upload_progress: std::collections::HashMap<usize, usize>,
|
||
|
||
/// Cached egui textures for video thumbnail strip rendering.
|
||
/// Key: (clip_id, thumbnail_timestamp_millis) → TextureHandle
|
||
video_thumbnail_textures: std::collections::HashMap<(uuid::Uuid, i64), egui::TextureHandle>,
|
||
|
||
/// Layer header drag-to-reorder state (None if not dragging a layer)
|
||
layer_drag: Option<LayerDragState>,
|
||
|
||
/// Cached mousedown position in header area (for drag threshold detection)
|
||
header_mousedown_pos: Option<egui::Pos2>,
|
||
/// Which audio/MIDI layers have automation lanes expanded
|
||
automation_expanded: std::collections::HashSet<uuid::Uuid>,
|
||
/// Cached automation lane info per layer_id
|
||
automation_cache: std::collections::HashMap<uuid::Uuid, Vec<AutomationLaneInfo>>,
|
||
/// Drag state per (layer_id, node_id) for in-progress keyframe moves
|
||
automation_drag: std::collections::HashMap<(uuid::Uuid, u32), Option<crate::curve_editor::CurveDragState>>,
|
||
/// Pending automation actions to process after render
|
||
pending_automation_actions: Vec<AutomationLaneAction>,
|
||
/// Last seen project_generation; used to detect node graph changes and invalidate automation cache
|
||
automation_cache_generation: u64,
|
||
/// Last seen graph_topology_generation; used to detect node additions/removals
|
||
automation_topology_generation: u64,
|
||
/// Last seen BPM; automation keyframe times rescale on BPM change, so clear cache when it differs
|
||
automation_cache_bpm: f64,
|
||
/// Cached metronome icon texture (loaded on first use)
|
||
metronome_icon: Option<egui::TextureHandle>,
|
||
/// Count-in pre-roll state: set when count-in is active, cleared when recording fires
|
||
pending_recording_start: Option<PendingRecordingStart>,
|
||
|
||
/// Layer currently being renamed via inline text edit (layer_id, buffer)
|
||
renaming_layer: Option<(uuid::Uuid, String)>,
|
||
|
||
/// BPM value captured when a drag/focus-in starts (for single-undo-action on commit)
|
||
bpm_drag_start: Option<f64>,
|
||
|
||
/// Whether to show the master track row in the timeline
|
||
pub show_master_track: bool,
|
||
}
|
||
|
||
/// Deferred recording start created during count-in pre-roll
|
||
struct PendingRecordingStart {
|
||
/// Original playhead position — where clips will be placed in the timeline
|
||
original_playhead: f64,
|
||
/// Transport time at which to fire the actual recording commands
|
||
trigger_time: f64,
|
||
}
|
||
|
||
/// Check if a clip type can be dropped on a layer type
|
||
fn can_drop_on_layer(layer: &AnyLayer, clip_type: DragClipType) -> bool {
|
||
match (layer, clip_type) {
|
||
(AnyLayer::Vector(_), DragClipType::Vector) => true,
|
||
(AnyLayer::Video(_), DragClipType::Video) => true,
|
||
(AnyLayer::Audio(audio), DragClipType::AudioSampled) => {
|
||
audio.audio_layer_type == AudioLayerType::Sampled
|
||
}
|
||
(AnyLayer::Audio(audio), DragClipType::AudioMidi) => {
|
||
audio.audio_layer_type == AudioLayerType::Midi
|
||
}
|
||
(AnyLayer::Effect(_), DragClipType::Effect) => true,
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// Represents a single row in the timeline's virtual layer list.
|
||
/// Expanded groups show their children directly (no separate header row).
|
||
/// Collapsed groups show as a single row with merged clips.
|
||
#[derive(Clone, Copy)]
|
||
#[allow(dead_code)]
|
||
enum TimelineRow<'a> {
|
||
/// A normal standalone layer (not in any group)
|
||
Normal(&'a AnyLayer),
|
||
/// A collapsed group -- single row with expand triangle and merged clips
|
||
CollapsedGroup { group: &'a GroupLayer, depth: u32 },
|
||
/// A child layer inside an expanded group
|
||
GroupChild {
|
||
child: &'a AnyLayer,
|
||
group: &'a GroupLayer, // the immediate parent group (for collapse action)
|
||
depth: u32, // nesting depth (1 = direct child of root group)
|
||
show_collapse: bool, // true for first visible child -- shows collapse triangle
|
||
},
|
||
}
|
||
|
||
impl<'a> TimelineRow<'a> {
|
||
fn layer_id(&self) -> uuid::Uuid {
|
||
match self {
|
||
TimelineRow::Normal(l) => l.id(),
|
||
TimelineRow::CollapsedGroup { group, .. } => group.layer.id,
|
||
TimelineRow::GroupChild { child, .. } => child.id(),
|
||
}
|
||
}
|
||
|
||
fn as_any_layer(&self) -> Option<&'a AnyLayer> {
|
||
match self {
|
||
TimelineRow::Normal(l) => Some(l),
|
||
TimelineRow::CollapsedGroup { .. } => None,
|
||
TimelineRow::GroupChild { child, .. } => Some(child),
|
||
}
|
||
}
|
||
|
||
/// Returns the parent group ID, or None if this row is at root level.
|
||
fn parent_id(&self) -> Option<uuid::Uuid> {
|
||
match self {
|
||
TimelineRow::GroupChild { group, .. } => Some(group.layer.id),
|
||
_ => None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Build a flattened list of timeline rows from the reversed context_layers.
|
||
/// Expanded groups emit their children directly (no header row).
|
||
/// Collapsed groups emit a single CollapsedGroup row.
|
||
fn build_timeline_rows<'a>(context_layers: &[&'a AnyLayer]) -> Vec<TimelineRow<'a>> {
|
||
let mut rows = Vec::new();
|
||
for &layer in context_layers.iter().rev() {
|
||
flatten_layer(layer, 0, None, &mut rows);
|
||
}
|
||
rows
|
||
}
|
||
|
||
fn flatten_layer<'a>(
|
||
layer: &'a AnyLayer,
|
||
depth: u32,
|
||
parent_group: Option<&'a GroupLayer>,
|
||
rows: &mut Vec<TimelineRow<'a>>,
|
||
) {
|
||
match layer {
|
||
AnyLayer::Group(g) if !g.expanded => {
|
||
rows.push(TimelineRow::CollapsedGroup { group: g, depth });
|
||
}
|
||
AnyLayer::Group(g) => {
|
||
// Expanded group: no header row, emit children directly.
|
||
// The first emitted row gets the collapse triangle for this group.
|
||
let mut first_emitted = true;
|
||
for child in &g.children {
|
||
let before_len = rows.len();
|
||
flatten_layer(child, depth + 1, Some(g), rows);
|
||
// Mark the first emitted GroupChild row with the collapse triangle
|
||
if first_emitted && rows.len() > before_len {
|
||
if let Some(TimelineRow::GroupChild { show_collapse, group, .. }) = rows.get_mut(before_len) {
|
||
*show_collapse = true;
|
||
*group = g; // point to THIS group for the collapse action
|
||
}
|
||
first_emitted = false;
|
||
}
|
||
}
|
||
}
|
||
_ => {
|
||
if depth > 0 {
|
||
if let Some(group) = parent_group {
|
||
rows.push(TimelineRow::GroupChild {
|
||
child: layer,
|
||
group,
|
||
depth,
|
||
show_collapse: false,
|
||
});
|
||
}
|
||
} else {
|
||
rows.push(TimelineRow::Normal(layer));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Cached automation lane data for timeline rendering
|
||
/// Distinguishes special master-track lanes from regular AutomationInput node lanes.
|
||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||
enum AutomationLaneKind {
|
||
/// Standard AutomationInput node in the audio graph
|
||
Regular,
|
||
/// Master track tempo lane — x-axis is beats, y-axis is BPM; purple accent
|
||
MasterTempo,
|
||
}
|
||
|
||
/// Sentinel node_id value for the master tempo virtual lane (never used by audio graph nodes)
|
||
const MASTER_TEMPO_NODE_ID: u32 = u32::MAX;
|
||
|
||
struct AutomationLaneInfo {
|
||
node_id: u32,
|
||
name: String,
|
||
keyframes: Vec<crate::curve_editor::CurvePoint>,
|
||
value_min: f32,
|
||
value_max: f32,
|
||
kind: AutomationLaneKind,
|
||
}
|
||
|
||
/// Data collected during render_layers for a single automation lane, used to call
|
||
/// render_curve_lane *after* handle_input so our widget registers last and wins priority.
|
||
struct AutomationLaneRender {
|
||
layer_id: uuid::Uuid,
|
||
node_id: u32,
|
||
lane_rect: egui::Rect,
|
||
keyframes: Vec<crate::curve_editor::CurvePoint>,
|
||
value_min: f32,
|
||
value_max: f32,
|
||
accent_color: egui::Color32,
|
||
playback_time: f64,
|
||
}
|
||
|
||
/// Pending automation keyframe edit action from curve lane interaction
|
||
enum AutomationLaneAction {
|
||
AddKeyframe { layer_id: uuid::Uuid, node_id: u32, time: f64, value: f32 },
|
||
MoveKeyframe { layer_id: uuid::Uuid, node_id: u32, old_time: f64, new_time: f64, new_value: f32, interpolation: String, ease_out: (f32, f32), ease_in: (f32, f32) },
|
||
DeleteKeyframe { layer_id: uuid::Uuid, node_id: u32, time: f64 },
|
||
SetRange { layer_id: uuid::Uuid, node_id: u32, min: f32, max: f32 },
|
||
}
|
||
|
||
/// Paint a soft drop shadow around a rect using gradient meshes (bottom + right + corner).
|
||
/// Three non-overlapping quads so alpha doesn't double up.
|
||
fn paint_drop_shadow(painter: &egui::Painter, rect: egui::Rect, shadow_size: f32, alpha: u8) {
|
||
let c = egui::Color32::from_black_alpha(alpha);
|
||
let t = egui::Color32::TRANSPARENT;
|
||
let mut mesh = egui::Mesh::default();
|
||
|
||
// Bottom edge: straight down, stops at right edge
|
||
let idx = mesh.vertices.len() as u32;
|
||
mesh.colored_vertex(rect.left_bottom(), c); // 0
|
||
mesh.colored_vertex(rect.right_bottom(), c); // 1
|
||
mesh.colored_vertex(egui::pos2(rect.right(), rect.bottom() + shadow_size), t); // 2
|
||
mesh.colored_vertex(egui::pos2(rect.left(), rect.bottom() + shadow_size), t); // 3
|
||
mesh.add_triangle(idx, idx + 1, idx + 2);
|
||
mesh.add_triangle(idx, idx + 2, idx + 3);
|
||
|
||
// Right edge: rightward, stops at bottom edge
|
||
let idx = mesh.vertices.len() as u32;
|
||
mesh.colored_vertex(rect.right_top(), c); // 0
|
||
mesh.colored_vertex(egui::pos2(rect.right() + shadow_size, rect.top()), t); // 1
|
||
mesh.colored_vertex(egui::pos2(rect.right() + shadow_size, rect.bottom()), t); // 2
|
||
mesh.colored_vertex(rect.right_bottom(), c); // 3
|
||
mesh.add_triangle(idx, idx + 1, idx + 2);
|
||
mesh.add_triangle(idx, idx + 2, idx + 3);
|
||
|
||
// Bottom-right corner: dark at inner corner, transparent at other three
|
||
let idx = mesh.vertices.len() as u32;
|
||
mesh.colored_vertex(rect.right_bottom(), c); // 0
|
||
mesh.colored_vertex(egui::pos2(rect.right() + shadow_size, rect.bottom()), t); // 1
|
||
mesh.colored_vertex(egui::pos2(rect.right() + shadow_size, rect.bottom() + shadow_size), t); // 2
|
||
mesh.colored_vertex(egui::pos2(rect.right(), rect.bottom() + shadow_size), t); // 3
|
||
mesh.add_triangle(idx, idx + 1, idx + 2);
|
||
mesh.add_triangle(idx, idx + 2, idx + 3);
|
||
|
||
painter.add(egui::Shape::mesh(mesh));
|
||
}
|
||
|
||
/// Shift+click layer selection: toggle a layer in/out of the focus selection,
|
||
/// enforcing the sibling constraint (all selected layers must share the same parent).
|
||
fn shift_toggle_layer(
|
||
focus: &mut lightningbeam_core::selection::FocusSelection,
|
||
layer_id: uuid::Uuid,
|
||
clicked_parent: Option<uuid::Uuid>,
|
||
rows: &[TimelineRow],
|
||
) {
|
||
use lightningbeam_core::selection::FocusSelection;
|
||
|
||
if let FocusSelection::Layers(ids) = focus {
|
||
// Check if existing selection shares the same parent as the clicked layer
|
||
let existing_parent = ids.first().and_then(|first_id| {
|
||
rows.iter()
|
||
.find(|r| r.layer_id() == *first_id)
|
||
.and_then(|r| r.parent_id())
|
||
});
|
||
// For root-level layers, existing_parent is None; for group children, it's Some(group_id)
|
||
// We need to compare them properly: both None means same parent (root)
|
||
let same_parent = if ids.is_empty() {
|
||
true
|
||
} else {
|
||
existing_parent == clicked_parent
|
||
};
|
||
|
||
if same_parent {
|
||
// Toggle the clicked layer in/out
|
||
if let Some(pos) = ids.iter().position(|id| *id == layer_id) {
|
||
ids.remove(pos);
|
||
if ids.is_empty() {
|
||
*focus = FocusSelection::None;
|
||
}
|
||
} else {
|
||
ids.push(layer_id);
|
||
}
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Different parent or focus wasn't Layers — start fresh
|
||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
|
||
}
|
||
|
||
/// Build a per-audio-layer clip instance cache from the backend snapshot.
|
||
///
|
||
/// Audio layers read clip instances from the backend snapshot (source of truth) rather
|
||
/// than from `AudioLayer::clip_instances`. The cache maps layer_id → Vec<ClipInstance>.
|
||
///
|
||
/// Clip instance UUIDs in the cache are doc UUIDs when available (via reverse lookup of
|
||
/// `clip_instance_to_backend_map`), falling back to synthetic `audio_backend_uuid` /
|
||
/// `midi_backend_uuid` values for clips not yet in the map.
|
||
fn build_audio_clip_cache(
|
||
snap: &daw_backend::AudioClipSnapshot,
|
||
layer_to_track_map: &HashMap<uuid::Uuid, daw_backend::TrackId>,
|
||
document: &lightningbeam_core::document::Document,
|
||
clip_map: &HashMap<uuid::Uuid, lightningbeam_core::action::BackendClipInstanceId>,
|
||
) -> HashMap<uuid::Uuid, Vec<ClipInstance>> {
|
||
use lightningbeam_core::action::BackendClipInstanceId;
|
||
|
||
// Build reverse maps: backend_id → doc_instance_uuid
|
||
let mut audio_id_to_doc: HashMap<u32, uuid::Uuid> = HashMap::new();
|
||
let mut midi_id_to_doc: HashMap<u32, uuid::Uuid> = HashMap::new();
|
||
for (&doc_uuid, backend_id) in clip_map {
|
||
match backend_id {
|
||
BackendClipInstanceId::Audio(id) => { audio_id_to_doc.insert(*id, doc_uuid); }
|
||
BackendClipInstanceId::Midi(id) => { midi_id_to_doc.insert(*id, doc_uuid); }
|
||
}
|
||
}
|
||
|
||
let mut cache: HashMap<uuid::Uuid, Vec<ClipInstance>> = HashMap::new();
|
||
|
||
for (&layer_id, &track_id) in layer_to_track_map {
|
||
// Only process audio layers
|
||
match document.get_layer(&layer_id) {
|
||
Some(AnyLayer::Audio(_)) => {}
|
||
_ => continue,
|
||
}
|
||
|
||
let mut instances = Vec::new();
|
||
|
||
// Sampled audio clips
|
||
if let Some(audio_clips) = snap.audio.get(&track_id) {
|
||
for ac in audio_clips {
|
||
if let Some((clip_id, _)) = document.audio_clip_by_pool_index(ac.audio_pool_index) {
|
||
// Use doc UUID if we have it; otherwise fall back to synthetic UUID
|
||
let instance_id = audio_id_to_doc.get(&ac.id)
|
||
.copied()
|
||
.unwrap_or_else(|| audio_backend_uuid(ac.id));
|
||
let mut ci = ClipInstance::new(clip_id);
|
||
ci.id = instance_id;
|
||
ci.timeline_start = ac.external_start.beats_to_f64();
|
||
ci.trim_start = ac.internal_start.seconds_to_f64();
|
||
ci.trim_end = Some(ac.internal_end.seconds_to_f64());
|
||
let internal_dur_secs = (ac.internal_end - ac.internal_start).seconds_to_f64();
|
||
let tempo_map = document.tempo_map();
|
||
let external_dur_secs = tempo_map.transform(ac.external_duration.beats_to_f64());
|
||
if (external_dur_secs - internal_dur_secs).abs() > 1e-9 {
|
||
ci.timeline_duration = Some(ac.external_duration.beats_to_f64());
|
||
}
|
||
ci.gain = ac.gain;
|
||
instances.push(ci);
|
||
}
|
||
}
|
||
}
|
||
|
||
// MIDI clips
|
||
if let Some(midi_clips) = snap.midi.get(&track_id) {
|
||
for mc in midi_clips {
|
||
if let Some((clip_id, _)) = document.audio_clip_by_midi_clip_id(mc.clip_id) {
|
||
let instance_id = midi_id_to_doc.get(&mc.id)
|
||
.copied()
|
||
.unwrap_or_else(|| midi_backend_uuid(mc.id));
|
||
let mut ci = ClipInstance::new(clip_id);
|
||
ci.id = instance_id;
|
||
ci.timeline_start = mc.external_start.beats_to_f64();
|
||
ci.trim_start = mc.internal_start.beats_to_f64();
|
||
ci.trim_end = Some(mc.internal_end.beats_to_f64());
|
||
// Always set timeline_duration for MIDI clips: duration is in beats, so we
|
||
// must bypass the content_window_secs * bpm/60 formula (which expects seconds).
|
||
ci.timeline_duration = Some(mc.external_duration.beats_to_f64());
|
||
instances.push(ci);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Only insert if we found clips (so layer_clips() can fall back to al.clip_instances
|
||
// for layers where the snapshot has no clips yet, e.g. during recording setup)
|
||
if !instances.is_empty() {
|
||
cache.insert(layer_id, instances);
|
||
}
|
||
}
|
||
|
||
cache
|
||
}
|
||
|
||
/// Get clip instances for a layer, using the snapshot-based cache for audio layers
|
||
/// and falling back to the doc's `clip_instances` if the cache has no entry OR is empty
|
||
/// while the doc has clips (e.g., a recording clip not yet reflected in the snapshot).
|
||
fn layer_clips<'a>(
|
||
layer: &'a AnyLayer,
|
||
audio_cache: &'a HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
||
) -> &'a [ClipInstance] {
|
||
match layer {
|
||
AnyLayer::Audio(al) => {
|
||
match audio_cache.get(&al.layer.id) {
|
||
Some(cached) if !cached.is_empty() => cached.as_slice(),
|
||
// Cache empty or missing: fall back to doc (covers recording-in-progress)
|
||
_ => &al.clip_instances,
|
||
}
|
||
}
|
||
AnyLayer::Vector(l) => &l.clip_instances,
|
||
AnyLayer::Video(l) => &l.clip_instances,
|
||
AnyLayer::Effect(l) => &l.clip_instances,
|
||
AnyLayer::Group(_) => &[],
|
||
AnyLayer::Raster(_) => &[],
|
||
}
|
||
}
|
||
|
||
/// Collect all (layer_ref, clip_instances) tuples from context_layers,
|
||
/// recursively descending into group children.
|
||
/// Returns (&AnyLayer, &[ClipInstance]) so callers have access to both layer info and clips.
|
||
fn all_layer_clip_instances<'a>(
|
||
context_layers: &[&'a AnyLayer],
|
||
audio_cache: &'a HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
||
) -> Vec<(&'a AnyLayer, &'a [ClipInstance])> {
|
||
let mut result = Vec::new();
|
||
for &layer in context_layers {
|
||
collect_clip_instances(layer, audio_cache, &mut result);
|
||
}
|
||
result
|
||
}
|
||
|
||
fn collect_clip_instances<'a>(
|
||
layer: &'a AnyLayer,
|
||
audio_cache: &'a HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
||
result: &mut Vec<(&'a AnyLayer, &'a [ClipInstance])>,
|
||
) {
|
||
match layer {
|
||
AnyLayer::Audio(_) => result.push((layer, layer_clips(layer, audio_cache))),
|
||
AnyLayer::Vector(l) => result.push((layer, &l.clip_instances)),
|
||
AnyLayer::Video(l) => result.push((layer, &l.clip_instances)),
|
||
AnyLayer::Effect(l) => result.push((layer, &l.clip_instances)),
|
||
AnyLayer::Group(g) => {
|
||
for child in &g.children {
|
||
collect_clip_instances(child, audio_cache, result);
|
||
}
|
||
}
|
||
AnyLayer::Raster(_) => {}
|
||
}
|
||
}
|
||
|
||
/// Find an existing sampled audio track in the document where a clip can be placed without overlap
|
||
/// Returns the layer ID if found, None otherwise
|
||
fn find_sampled_audio_track_for_clip(
|
||
document: &lightningbeam_core::document::Document,
|
||
clip_id: uuid::Uuid,
|
||
timeline_start: f64,
|
||
editing_clip_id: Option<&uuid::Uuid>,
|
||
) -> Option<uuid::Uuid> {
|
||
// Get the clip duration
|
||
let clip_duration = document.get_clip_duration(&clip_id)?;
|
||
let clip_end = timeline_start + clip_duration;
|
||
|
||
// Check each sampled audio layer
|
||
let context_layers = document.context_layers(editing_clip_id);
|
||
for &layer in &context_layers {
|
||
if let AnyLayer::Audio(audio_layer) = layer {
|
||
if audio_layer.audio_layer_type == AudioLayerType::Sampled {
|
||
// Check if there's any overlap with existing clips on this layer
|
||
let (overlaps, _) = document.check_overlap_on_layer(
|
||
&audio_layer.layer.id,
|
||
timeline_start,
|
||
clip_end,
|
||
&[], // Don't exclude any instances
|
||
);
|
||
|
||
if !overlaps {
|
||
// Found a suitable layer
|
||
return Some(audio_layer.layer.id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Get layer type display name and color for an AnyLayer
|
||
fn layer_type_info(layer: &AnyLayer) -> (&'static str, egui::Color32) {
|
||
match layer {
|
||
AnyLayer::Vector(_) => ("Vector", egui::Color32::from_rgb(255, 180, 100)),
|
||
AnyLayer::Audio(al) => match al.audio_layer_type {
|
||
AudioLayerType::Midi => ("MIDI", egui::Color32::from_rgb(100, 255, 150)),
|
||
AudioLayerType::Sampled => ("Audio", egui::Color32::from_rgb(100, 180, 255)),
|
||
},
|
||
AnyLayer::Video(_) => ("Video", egui::Color32::from_rgb(180, 100, 255)),
|
||
AnyLayer::Effect(_) => ("Effect", egui::Color32::from_rgb(255, 100, 180)),
|
||
AnyLayer::Group(_) => ("Group", egui::Color32::from_rgb(0, 180, 180)),
|
||
AnyLayer::Raster(_) => ("Raster", egui::Color32::from_rgb(160, 100, 200)),
|
||
}
|
||
}
|
||
|
||
impl TimelinePane {
|
||
pub fn new() -> Self {
|
||
Self {
|
||
pixels_per_second: 100.0,
|
||
viewport_start_time: 0.0,
|
||
viewport_scroll_y: 0.0,
|
||
keyframe_diamond_hits: Vec::new(),
|
||
duration: 10.0, // Default 10 seconds
|
||
is_scrubbing: false,
|
||
is_panning: false,
|
||
last_pan_pos: None,
|
||
clip_drag_state: None,
|
||
drag_offset: 0.0,
|
||
drag_anchor_start: 0.0,
|
||
mousedown_pos: None,
|
||
layer_control_clicked: false,
|
||
context_menu_clip: None,
|
||
time_display_format: TimelineMode::Seconds,
|
||
waveform_upload_progress: std::collections::HashMap::new(),
|
||
video_thumbnail_textures: std::collections::HashMap::new(),
|
||
layer_drag: None,
|
||
header_mousedown_pos: None,
|
||
automation_expanded: std::collections::HashSet::new(),
|
||
automation_cache: std::collections::HashMap::new(),
|
||
automation_drag: std::collections::HashMap::new(),
|
||
pending_automation_actions: Vec::new(),
|
||
automation_cache_generation: u64::MAX,
|
||
automation_topology_generation: u64::MAX,
|
||
automation_cache_bpm: 0.0,
|
||
metronome_icon: None,
|
||
pending_recording_start: None,
|
||
renaming_layer: None,
|
||
bpm_drag_start: None,
|
||
show_master_track: false,
|
||
}
|
||
}
|
||
|
||
/// Returns true if the timeline is currently in Measures display mode.
|
||
pub fn is_measures_mode(&self) -> bool {
|
||
self.time_display_format == TimelineMode::Measures
|
||
}
|
||
|
||
/// Execute a view action with the given parameters
|
||
/// Called from main.rs after determining this is the best handler
|
||
#[allow(dead_code)] // Mirrors StagePane; wiring in main.rs pending (see TODO at view action dispatch)
|
||
pub fn execute_view_action(&mut self, action: &crate::menu::MenuAction, zoom_center: egui::Vec2) {
|
||
use crate::menu::MenuAction;
|
||
match action {
|
||
MenuAction::ZoomIn => self.zoom_in(zoom_center.x),
|
||
MenuAction::ZoomOut => self.zoom_out(zoom_center.x),
|
||
MenuAction::ActualSize => self.actual_size(),
|
||
MenuAction::RecenterView => self.recenter(),
|
||
_ => {} // Not a view action we handle
|
||
}
|
||
}
|
||
|
||
/// Extra height added to a layer row when automation lanes are expanded
|
||
fn automation_lanes_height(&self, layer_id: uuid::Uuid) -> f32 {
|
||
if !self.automation_expanded.contains(&layer_id) {
|
||
return 0.0;
|
||
}
|
||
let n = self.automation_cache.get(&layer_id).map_or(0, |v| v.len());
|
||
n as f32 * AUTOMATION_LANE_HEIGHT
|
||
}
|
||
|
||
/// Total height of a timeline row (LAYER_HEIGHT + automation lanes if expanded)
|
||
fn row_height(&self, row: &TimelineRow) -> f32 {
|
||
LAYER_HEIGHT + self.automation_lanes_height(row.layer_id())
|
||
}
|
||
|
||
/// Cumulative Y offset from top of rows area to the start of row at `idx`
|
||
fn cumulative_row_y(&self, rows: &[TimelineRow], idx: usize) -> f32 {
|
||
rows[..idx].iter().map(|r| self.row_height(r)).sum()
|
||
}
|
||
|
||
/// Find which row contains `relative_y` (measured from top of rows area).
|
||
/// Returns (row_index, y_within_row).
|
||
fn row_at_y(&self, rows: &[TimelineRow], relative_y: f32) -> Option<(usize, f32)> {
|
||
let mut y = 0.0f32;
|
||
for (i, row) in rows.iter().enumerate() {
|
||
let h = self.row_height(row);
|
||
if relative_y >= y && relative_y < y + h {
|
||
return Some((i, relative_y - y));
|
||
}
|
||
y += h;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Refresh the automation lane cache for a layer by querying the backend.
|
||
fn refresh_automation_cache(
|
||
&mut self,
|
||
layer_id: uuid::Uuid,
|
||
controller: &mut daw_backend::EngineController,
|
||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, daw_backend::TrackId>,
|
||
master_layer_id: uuid::Uuid,
|
||
tempo_map: &daw_backend::TempoMap,
|
||
) {
|
||
let track_id = match layer_to_track_map.get(&layer_id) {
|
||
Some(t) => *t,
|
||
None => return,
|
||
};
|
||
|
||
// Query the graph state JSON
|
||
let json = match controller.query_graph_state(track_id) {
|
||
Ok(j) => j,
|
||
Err(_) => return,
|
||
};
|
||
|
||
let preset: daw_backend::GraphPreset = match serde_json::from_str(&json) {
|
||
Ok(p) => p,
|
||
Err(_) => return,
|
||
};
|
||
|
||
let mut lanes = Vec::new();
|
||
for node in &preset.nodes {
|
||
if node.node_type != "AutomationInput" {
|
||
continue;
|
||
}
|
||
let name = controller
|
||
.query_automation_name(track_id, node.id)
|
||
.unwrap_or_else(|_| "Automation".to_string());
|
||
let keyframes = controller
|
||
.query_automation_keyframes(track_id, node.id)
|
||
.unwrap_or_default()
|
||
.iter()
|
||
.map(|k| crate::curve_editor::CurvePoint {
|
||
time: k.time, // beats (backend stores beats; curve editor x-axis is beats)
|
||
value: k.value,
|
||
interpolation: match k.interpolation.as_str() {
|
||
"bezier" => crate::curve_editor::CurveInterpolation::Bezier,
|
||
"step" => crate::curve_editor::CurveInterpolation::Step,
|
||
"hold" => crate::curve_editor::CurveInterpolation::Hold,
|
||
_ => crate::curve_editor::CurveInterpolation::Linear,
|
||
},
|
||
ease_out: k.ease_out,
|
||
ease_in: k.ease_in,
|
||
})
|
||
.collect();
|
||
let (value_min, value_max) = controller
|
||
.query_automation_range(track_id, node.id)
|
||
.unwrap_or((-1.0, 1.0));
|
||
lanes.push(AutomationLaneInfo { node_id: node.id, name, keyframes, value_min, value_max, kind: AutomationLaneKind::Regular });
|
||
}
|
||
|
||
// For the master layer, append the special Tempo virtual lane after the regular lanes.
|
||
if layer_id == master_layer_id {
|
||
let tempo_keyframes = tempo_map.entries.iter().map(|entry| {
|
||
crate::curve_editor::CurvePoint {
|
||
time: entry.beat, // beats (not seconds); beat-based closures in render_curve_lane
|
||
value: entry.bpm as f32,
|
||
interpolation: match entry.interpolation {
|
||
daw_backend::TempoInterpolation::Linear => crate::curve_editor::CurveInterpolation::Linear,
|
||
daw_backend::TempoInterpolation::Step => crate::curve_editor::CurveInterpolation::Step,
|
||
},
|
||
ease_out: (0.0, 0.0),
|
||
ease_in: (0.0, 0.0),
|
||
}
|
||
}).collect();
|
||
lanes.push(AutomationLaneInfo {
|
||
node_id: MASTER_TEMPO_NODE_ID,
|
||
name: "Tempo".to_string(),
|
||
keyframes: tempo_keyframes,
|
||
value_min: 20.0,
|
||
value_max: 300.0,
|
||
kind: AutomationLaneKind::MasterTempo,
|
||
});
|
||
}
|
||
|
||
self.automation_cache.insert(layer_id, lanes);
|
||
}
|
||
|
||
/// Toggle recording on/off
|
||
/// In Auto mode, records to the active layer (audio or video with camera)
|
||
fn toggle_recording(&mut self, shared: &mut SharedPaneState) {
|
||
if *shared.is_recording {
|
||
// Stop recording
|
||
self.stop_recording(shared);
|
||
} else {
|
||
// Start recording on active layer
|
||
self.start_recording(shared);
|
||
}
|
||
}
|
||
|
||
/// Start recording on all selected recordable layers (or the active layer as fallback).
|
||
/// Groups are recursed into. At most one layer per recording type is recorded to
|
||
/// (topmost in visual order wins).
|
||
fn start_recording(&mut self, shared: &mut SharedPaneState) {
|
||
use lightningbeam_core::clip::{AudioClip, ClipInstance};
|
||
|
||
// Step 1: Collect candidate layer IDs from focus selection, falling back to active layer
|
||
let candidate_ids: Vec<uuid::Uuid> = match shared.focus {
|
||
lightningbeam_core::selection::FocusSelection::Layers(ref ids) if !ids.is_empty() => {
|
||
ids.clone()
|
||
}
|
||
_ => {
|
||
if let Some(id) = *shared.active_layer_id {
|
||
vec![id]
|
||
} else {
|
||
println!("⚠️ No active layer selected for recording");
|
||
return;
|
||
}
|
||
}
|
||
};
|
||
|
||
// Step 2: Resolve layers, recursing into groups to collect recordable leaves.
|
||
// Categorize by recording type. Use visual ordering (build_timeline_rows) to pick topmost.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
enum RecordCandidate {
|
||
AudioSampled,
|
||
AudioMidi,
|
||
VideoCamera,
|
||
}
|
||
|
||
let mut candidates: Vec<(uuid::Uuid, RecordCandidate, usize)> = Vec::new(); // (layer_id, type, visual_row_index)
|
||
|
||
{
|
||
let document = shared.action_executor.document();
|
||
let context_layers = document.context_layers(shared.editing_clip_id.as_ref());
|
||
let rows = build_timeline_rows(&context_layers);
|
||
|
||
// Helper: collect recordable leaf layer IDs from a layer (recurse into groups)
|
||
fn collect_recordable_leaves(layer: &AnyLayer, out: &mut Vec<uuid::Uuid>) {
|
||
match layer {
|
||
AnyLayer::Audio(_) => out.push(layer.id()),
|
||
AnyLayer::Video(v) if v.camera_enabled => out.push(layer.id()),
|
||
AnyLayer::Group(g) => {
|
||
for child in &g.children {
|
||
collect_recordable_leaves(child, out);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
let mut leaf_ids: Vec<uuid::Uuid> = Vec::new();
|
||
for cid in &candidate_ids {
|
||
if let Some(layer) = context_layers.iter().copied().find(|l| l.id() == *cid) {
|
||
collect_recordable_leaves(layer, &mut leaf_ids);
|
||
} else {
|
||
// Try deeper in the tree (for layers inside groups)
|
||
if let Some(layer) = document.root.get_child(cid) {
|
||
collect_recordable_leaves(layer, &mut leaf_ids);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Deduplicate
|
||
leaf_ids.sort();
|
||
leaf_ids.dedup();
|
||
|
||
// Categorize and find visual row index for ordering
|
||
for leaf_id in &leaf_ids {
|
||
let visual_idx = rows.iter().position(|r| r.layer_id() == *leaf_id).unwrap_or(usize::MAX);
|
||
if let Some(layer) = document.root.get_child(leaf_id).or_else(|| {
|
||
context_layers.iter().copied().find(|l| l.id() == *leaf_id)
|
||
}) {
|
||
let cat = match layer {
|
||
AnyLayer::Audio(a) => match a.audio_layer_type {
|
||
AudioLayerType::Sampled => Some(RecordCandidate::AudioSampled),
|
||
AudioLayerType::Midi => Some(RecordCandidate::AudioMidi),
|
||
},
|
||
AnyLayer::Video(v) if v.camera_enabled => Some(RecordCandidate::VideoCamera),
|
||
_ => None,
|
||
};
|
||
if let Some(cat) = cat {
|
||
candidates.push((*leaf_id, cat, visual_idx));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if candidates.is_empty() {
|
||
println!("⚠️ No recordable layers in selection");
|
||
return;
|
||
}
|
||
|
||
// Step 3: Sort by visual position (topmost first) and deduplicate by type
|
||
candidates.sort_by_key(|c| c.2);
|
||
let mut seen_sampled = false;
|
||
let mut seen_midi = false;
|
||
let mut seen_webcam = false;
|
||
candidates.retain(|c| {
|
||
match c.1 {
|
||
RecordCandidate::AudioSampled => {
|
||
if seen_sampled { return false; }
|
||
seen_sampled = true;
|
||
}
|
||
RecordCandidate::AudioMidi => {
|
||
if seen_midi { return false; }
|
||
seen_midi = true;
|
||
}
|
||
RecordCandidate::VideoCamera => {
|
||
if seen_webcam { return false; }
|
||
seen_webcam = true;
|
||
}
|
||
}
|
||
true
|
||
});
|
||
|
||
let start_time = *shared.playback_time;
|
||
|
||
// Count-in: seek back N beats, start transport + metronome, defer ALL recording commands.
|
||
// Must happen before Step 4 so no clips or backend recordings are created yet.
|
||
if *shared.count_in_enabled
|
||
&& *shared.metronome_enabled
|
||
&& self.time_display_format == TimelineMode::Measures
|
||
{
|
||
let (tempo_map, beats_per_measure) = {
|
||
let doc = shared.action_executor.document();
|
||
(doc.tempo_map().clone(), doc.time_signature.numerator as f64)
|
||
};
|
||
// Convert start_time to beats, subtract one measure, convert back to seconds
|
||
let start_beat = tempo_map.inverse_transform(start_time);
|
||
let count_in_start_beat = start_beat - beats_per_measure;
|
||
let seek_to = if count_in_start_beat >= 0.0 {
|
||
tempo_map.transform(count_in_start_beat)
|
||
} else {
|
||
// Before beat 0: extrapolate using initial BPM
|
||
tempo_map.transform(0.0) - count_in_start_beat.abs() * (60.0 / tempo_map.global_bpm())
|
||
};
|
||
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(seek_to);
|
||
controller.set_metronome_enabled(true);
|
||
if !*shared.is_playing {
|
||
controller.play();
|
||
*shared.is_playing = true;
|
||
}
|
||
}
|
||
|
||
self.pending_recording_start = Some(PendingRecordingStart {
|
||
original_playhead: start_time,
|
||
trigger_time: start_time,
|
||
});
|
||
return; // Recording commands are deferred — check_pending_recording_start fires them
|
||
}
|
||
|
||
shared.recording_layer_ids.clear();
|
||
|
||
// Step 4: Dispatch recording for each candidate
|
||
for &(layer_id, ref cat, _) in &candidates {
|
||
match cat {
|
||
RecordCandidate::VideoCamera => {
|
||
*shared.webcam_record_command = Some(super::WebcamRecordCommand::Start {
|
||
layer_id,
|
||
});
|
||
shared.recording_layer_ids.push(layer_id);
|
||
println!("📹 Started webcam recording on layer {}", layer_id);
|
||
}
|
||
RecordCandidate::AudioSampled => {
|
||
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||
// Open the input stream now if it hasn't been opened yet.
|
||
if shared.audio_input_stream.is_none() {
|
||
if let Some(opener) = shared.audio_input_opener.take() {
|
||
match opener.open(shared.audio_buffer_size) {
|
||
Ok(stream) => *shared.audio_input_stream = Some(stream),
|
||
Err(e) => eprintln!("⚠️ Could not open input stream: {e}"),
|
||
}
|
||
}
|
||
}
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.start_recording(track_id, start_time);
|
||
println!("🎤 Started audio recording on track {:?} at {:.2}s", track_id, start_time);
|
||
}
|
||
shared.recording_layer_ids.push(layer_id);
|
||
} else {
|
||
println!("⚠️ No backend track mapped for layer {}", layer_id);
|
||
}
|
||
}
|
||
RecordCandidate::AudioMidi => {
|
||
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
let clip_id = controller.create_midi_clip(track_id, start_time, 0.0);
|
||
controller.start_midi_recording(track_id, clip_id, start_time);
|
||
shared.recording_clips.insert(layer_id, clip_id);
|
||
println!("🎹 Started MIDI recording on track {:?} at {:.2}s, clip_id={}",
|
||
track_id, start_time, clip_id);
|
||
}
|
||
|
||
// Create document clip + clip instance immediately
|
||
let doc_clip = AudioClip::new_midi("Recording...",
|
||
*shared.recording_clips.get(&layer_id).unwrap_or(&0), daw_backend::Beats::ZERO);
|
||
let doc_clip_id = shared.action_executor.document_mut().add_audio_clip(doc_clip);
|
||
|
||
let clip_instance = ClipInstance::new(doc_clip_id)
|
||
.with_timeline_start(start_time);
|
||
|
||
if let Some(layer) = shared.action_executor.document_mut().get_layer_mut(&layer_id) {
|
||
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer {
|
||
audio_layer.clip_instances.push(clip_instance);
|
||
}
|
||
}
|
||
|
||
// Initialize empty cache entry
|
||
if let Some(&clip_id) = shared.recording_clips.get(&layer_id) {
|
||
shared.midi_event_cache.insert(clip_id, Vec::new());
|
||
}
|
||
|
||
shared.recording_layer_ids.push(layer_id);
|
||
} else {
|
||
println!("⚠️ No backend track mapped for layer {}", layer_id);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if shared.recording_layer_ids.is_empty() {
|
||
println!("⚠️ Failed to start recording on any layer");
|
||
return;
|
||
}
|
||
|
||
// No count-in (or deferred re-call from check_pending_recording_start):
|
||
// Start immediately. Metronome must be enabled BEFORE play() so beat 0 is not missed.
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
if *shared.metronome_enabled {
|
||
controller.set_metronome_enabled(true);
|
||
}
|
||
if !*shared.is_playing {
|
||
controller.play();
|
||
*shared.is_playing = true;
|
||
println!("▶ Auto-started playback for recording");
|
||
}
|
||
}
|
||
|
||
*shared.is_recording = true;
|
||
*shared.recording_start_time = start_time;
|
||
}
|
||
|
||
/// Stop all active recordings
|
||
/// Called every frame; fires deferred recording commands once the count-in pre-roll ends.
|
||
fn check_pending_recording_start(&mut self, shared: &mut SharedPaneState) {
|
||
let Some(ref pending) = self.pending_recording_start else { return };
|
||
if !*shared.is_playing || *shared.playback_time < pending.trigger_time {
|
||
return;
|
||
}
|
||
let original_playhead = pending.original_playhead;
|
||
self.pending_recording_start = None;
|
||
|
||
// Re-run start_recording with playback_time temporarily set to the original playhead
|
||
// so it captures the correct start_time and places clips at the right position.
|
||
// Disable count_in_enabled so start_recording takes the immediate path (no re-seek).
|
||
let saved_time = *shared.playback_time;
|
||
let saved_count_in = *shared.count_in_enabled;
|
||
*shared.playback_time = original_playhead;
|
||
*shared.count_in_enabled = false;
|
||
self.start_recording(shared);
|
||
*shared.playback_time = saved_time;
|
||
*shared.count_in_enabled = saved_count_in;
|
||
}
|
||
|
||
fn stop_recording(&mut self, shared: &mut SharedPaneState) {
|
||
// Cancel any in-progress count-in
|
||
self.pending_recording_start = None;
|
||
|
||
let stop_wall = std::time::Instant::now();
|
||
eprintln!("[STOP] stop_recording called at {:?}", stop_wall);
|
||
|
||
// Determine which recording types are active by checking recording_layer_ids
|
||
let mut has_audio = false;
|
||
let mut has_midi = false;
|
||
let mut has_webcam = false;
|
||
|
||
{
|
||
let document = shared.action_executor.document();
|
||
for layer_id in shared.recording_layer_ids.iter() {
|
||
if let Some(layer) = document.root.get_child(layer_id) {
|
||
match layer {
|
||
lightningbeam_core::layer::AnyLayer::Audio(a) => {
|
||
match a.audio_layer_type {
|
||
lightningbeam_core::layer::AudioLayerType::Sampled => has_audio = true,
|
||
lightningbeam_core::layer::AudioLayerType::Midi => has_midi = true,
|
||
}
|
||
}
|
||
lightningbeam_core::layer::AnyLayer::Video(v) if v.camera_enabled => {
|
||
has_webcam = true;
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if has_webcam {
|
||
*shared.webcam_record_command = Some(super::WebcamRecordCommand::Stop);
|
||
eprintln!("[STOP] Webcam stop command queued at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0);
|
||
}
|
||
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
if has_midi {
|
||
controller.stop_midi_recording();
|
||
eprintln!("[STOP] MIDI stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0);
|
||
}
|
||
if has_audio {
|
||
controller.stop_recording();
|
||
eprintln!("[STOP] Audio stop command sent at +{:.1}ms", stop_wall.elapsed().as_secs_f64() * 1000.0);
|
||
}
|
||
// Always disable metronome on recording stop
|
||
controller.set_metronome_enabled(false);
|
||
}
|
||
|
||
// Note: Don't clear recording_layer_ids here!
|
||
// The RecordingStopped/MidiRecordingStopped event handlers in main.rs
|
||
// need them to finalize clips. They will clear the state after processing.
|
||
// Only clear is_recording to update UI state immediately.
|
||
*shared.is_recording = false;
|
||
}
|
||
|
||
/// Detect which clip is under the pointer and what type of drag would occur
|
||
///
|
||
/// Returns (drag_type, clip_id) if pointer is over a clip, None otherwise
|
||
fn detect_clip_at_pointer(
|
||
&self,
|
||
pointer_pos: egui::Pos2,
|
||
document: &lightningbeam_core::document::Document,
|
||
content_rect: egui::Rect,
|
||
header_rect: egui::Rect,
|
||
editing_clip_id: Option<&uuid::Uuid>,
|
||
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
||
) -> Option<(ClipDragType, uuid::Uuid)> {
|
||
let context_layers = document.context_layers(editing_clip_id);
|
||
let rows = build_timeline_rows(&context_layers);
|
||
let layer_count = rows.len();
|
||
|
||
// Check if pointer is in valid area
|
||
if pointer_pos.y < header_rect.min.y {
|
||
return None;
|
||
}
|
||
if pointer_pos.x < content_rect.min.x {
|
||
return None;
|
||
}
|
||
|
||
let relative_y = pointer_pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||
let (hovered_layer_index, _y_within_row) = match self.row_at_y(&rows, relative_y) {
|
||
Some(v) => v,
|
||
None => return None,
|
||
};
|
||
let _ = layer_count; // suppress unused warning
|
||
|
||
let row = &rows[hovered_layer_index];
|
||
// Collapsed groups have no directly clickable clips
|
||
let layer: &AnyLayer = match row {
|
||
TimelineRow::Normal(l) => l,
|
||
TimelineRow::GroupChild { child, .. } => child,
|
||
TimelineRow::CollapsedGroup { .. } => return None,
|
||
};
|
||
let _layer_data = layer.layer();
|
||
|
||
let clip_instances = layer_clips(layer, audio_cache);
|
||
|
||
// Check each clip instance
|
||
let stacking = compute_clip_stacking(document, layer, clip_instances);
|
||
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
|
||
let clip_duration = effective_clip_duration(document, layer, clip_instance)?;
|
||
|
||
let tempo_map = document.tempo_map();
|
||
let instance_start = clip_instance.effective_start();
|
||
let instance_duration = clip_instance.total_duration(clip_duration, tempo_map);
|
||
let instance_end = instance_start + instance_duration;
|
||
|
||
let start_x = self.time_to_x(instance_start);
|
||
let end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX);
|
||
let mouse_x = pointer_pos.x - content_rect.min.x;
|
||
|
||
if mouse_x >= start_x && mouse_x <= end_x {
|
||
// Check vertical bounds for stacked vector layer clips
|
||
let layer_top = header_rect.min.y + self.cumulative_row_y(&rows, hovered_layer_index) - self.viewport_scroll_y;
|
||
let (row, total_rows) = stacking[ci_idx];
|
||
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
|
||
let mouse_rel_y = pointer_pos.y - layer_top;
|
||
if mouse_rel_y < cy_min || mouse_rel_y > cy_max {
|
||
continue;
|
||
}
|
||
|
||
// Determine drag type based on edge proximity (check both sides of edge)
|
||
let is_audio_layer = matches!(layer, lightningbeam_core::layer::AnyLayer::Audio(_));
|
||
let mouse_in_top_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE;
|
||
|
||
let is_looping = clip_instance.timeline_duration.is_some() || clip_instance.loop_before.is_some();
|
||
let drag_type = if (mouse_x - start_x).abs() <= EDGE_DETECTION_PIXELS {
|
||
// Left edge: loop extend left for audio clips that are looping or top-left corner
|
||
let mouse_in_top_left_corner = pointer_pos.y < layer_top + LOOP_CORNER_SIZE;
|
||
if is_audio_layer && (is_looping || mouse_in_top_left_corner) {
|
||
ClipDragType::LoopExtendLeft
|
||
} else {
|
||
ClipDragType::TrimLeft
|
||
}
|
||
} else if (end_x - mouse_x).abs() <= EDGE_DETECTION_PIXELS {
|
||
// If already looping, right edge is always loop extend
|
||
// Otherwise, top-right corner of audio clips = loop extend
|
||
if is_audio_layer && (is_looping || mouse_in_top_corner) {
|
||
ClipDragType::LoopExtendRight
|
||
} else {
|
||
ClipDragType::TrimRight
|
||
}
|
||
} else {
|
||
ClipDragType::Move
|
||
};
|
||
|
||
return Some((drag_type, clip_instance.id));
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Detect if the pointer is over a merged span in a collapsed group row.
|
||
/// Returns all child clip instance IDs that contribute to the hit span.
|
||
fn detect_collapsed_group_at_pointer(
|
||
&self,
|
||
pointer_pos: egui::Pos2,
|
||
document: &lightningbeam_core::document::Document,
|
||
content_rect: egui::Rect,
|
||
header_rect: egui::Rect,
|
||
editing_clip_id: Option<&uuid::Uuid>,
|
||
) -> Option<Vec<uuid::Uuid>> {
|
||
let context_layers = document.context_layers(editing_clip_id);
|
||
let rows = build_timeline_rows(&context_layers);
|
||
|
||
if pointer_pos.y < header_rect.min.y || pointer_pos.x < content_rect.min.x {
|
||
return None;
|
||
}
|
||
|
||
let relative_y = pointer_pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||
let (hovered_index, _) = match self.row_at_y(&rows, relative_y) {
|
||
Some(v) => v,
|
||
None => return None,
|
||
};
|
||
|
||
let TimelineRow::CollapsedGroup { group, .. } = &rows[hovered_index] else {
|
||
return None;
|
||
};
|
||
|
||
// Compute merged spans with the child clip IDs that contribute to each
|
||
let child_clips = group.all_child_clip_instances();
|
||
let mut spans: Vec<(f64, f64, Vec<uuid::Uuid>)> = Vec::new(); // (start, end, clip_ids)
|
||
|
||
let tempo_map = document.tempo_map();
|
||
for (_child_layer_id, ci) in &child_clips {
|
||
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
||
ci.trim_end.unwrap_or(1.0) - ci.trim_start
|
||
});
|
||
let start = ci.effective_start();
|
||
let end = start + ci.total_duration(clip_dur, tempo_map);
|
||
spans.push((start, end, vec![ci.id]));
|
||
}
|
||
|
||
spans.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||
|
||
// Merge overlapping spans
|
||
let mut merged: Vec<(f64, f64, Vec<uuid::Uuid>)> = Vec::new();
|
||
for (s, e, ids) in spans {
|
||
if let Some(last) = merged.last_mut() {
|
||
if s <= last.1 {
|
||
last.1 = last.1.max(e);
|
||
last.2.extend(ids);
|
||
} else {
|
||
merged.push((s, e, ids));
|
||
}
|
||
} else {
|
||
merged.push((s, e, ids));
|
||
}
|
||
}
|
||
|
||
// Check which merged span the pointer is over
|
||
let mouse_x = pointer_pos.x - content_rect.min.x;
|
||
for (s, e, ids) in merged {
|
||
let sx = self.time_to_x(s);
|
||
let ex = self.time_to_x(e).max(sx + MIN_CLIP_WIDTH_PX);
|
||
if mouse_x >= sx && mouse_x <= ex {
|
||
return Some(ids);
|
||
}
|
||
}
|
||
|
||
None
|
||
}
|
||
|
||
/// Zoom in by a fixed increment
|
||
pub fn zoom_in(&mut self, center_x: f32) {
|
||
self.apply_zoom_at_point(0.2, center_x);
|
||
}
|
||
|
||
/// Zoom out by a fixed increment
|
||
pub fn zoom_out(&mut self, center_x: f32) {
|
||
self.apply_zoom_at_point(-0.2, center_x);
|
||
}
|
||
|
||
/// Reset zoom to 100 pixels per second
|
||
pub fn actual_size(&mut self) {
|
||
self.pixels_per_second = 100.0;
|
||
}
|
||
|
||
/// Reset pan to start and zoom to default
|
||
pub fn recenter(&mut self) {
|
||
self.viewport_start_time = 0.0;
|
||
self.viewport_scroll_y = 0.0;
|
||
self.pixels_per_second = 100.0;
|
||
}
|
||
|
||
/// Apply zoom while keeping the time under the cursor stationary
|
||
fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) {
|
||
let old_zoom = self.pixels_per_second;
|
||
|
||
// Calculate time position under mouse before zoom
|
||
let time_at_mouse = self.x_to_time(mouse_x);
|
||
|
||
// Apply zoom
|
||
let new_zoom = (old_zoom * (1.0 + zoom_delta)).clamp(MIN_PIXELS_PER_SECOND, MAX_PIXELS_PER_SECOND);
|
||
self.pixels_per_second = new_zoom;
|
||
|
||
// Adjust viewport so the same time stays under the mouse
|
||
let new_mouse_x = self.time_to_x(time_at_mouse);
|
||
let time_delta = (new_mouse_x - mouse_x) / new_zoom;
|
||
self.viewport_start_time = (self.viewport_start_time + time_delta as f64).max(0.0);
|
||
}
|
||
|
||
/// Convert time (seconds) to pixel x-coordinate
|
||
fn time_to_x(&self, time: f64) -> f32 {
|
||
((time - self.viewport_start_time) * self.pixels_per_second as f64) as f32
|
||
}
|
||
|
||
/// Effective display start for a clip instance, in seconds.
|
||
///
|
||
/// `timeline_start` is in beats; converts to seconds using the current (preview) BPM so
|
||
/// clips stay anchored to their beat position during live BPM drag.
|
||
fn instance_display_start(&self, ci: &lightningbeam_core::clip::ClipInstance, tempo_map: &daw_backend::TempoMap) -> f64 {
|
||
tempo_map.transform(ci.effective_start())
|
||
}
|
||
|
||
/// Effective on-timeline duration for a clip instance, in seconds.
|
||
///
|
||
/// `total_duration` is in beats; converts to seconds using the current (preview) BPM.
|
||
fn instance_display_duration(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, tempo_map: &daw_backend::TempoMap) -> f64 {
|
||
tempo_map.transform(ci.timeline_start + ci.total_duration(clip_dur_secs, tempo_map)) - tempo_map.transform(ci.effective_start())
|
||
}
|
||
|
||
/// Returns the clip content start (trim_start) and duration in display seconds.
|
||
fn content_display_range(&self, ci: &lightningbeam_core::clip::ClipInstance, clip_dur_secs: f64, _bpm: f64) -> (f64, f64) {
|
||
let trim_end = ci.trim_end.unwrap_or(clip_dur_secs);
|
||
(ci.trim_start, (trim_end - ci.trim_start).max(0.0))
|
||
}
|
||
|
||
/// Convert pixel x-coordinate to time (seconds)
|
||
fn x_to_time(&self, x: f32) -> f64 {
|
||
self.viewport_start_time + (x / self.pixels_per_second) as f64
|
||
}
|
||
|
||
/// Returns the quantization grid size in seconds, or None to disable snapping.
|
||
/// - Measures mode: zoom-adaptive (coarser when zoomed out, None when very zoomed in)
|
||
/// - Frames mode: always 1/framerate regardless of zoom
|
||
/// - Seconds mode: no snapping
|
||
fn quantize_grid_size(
|
||
&self,
|
||
tempo_map: &daw_backend::TempoMap,
|
||
time_sig: &lightningbeam_core::document::TimeSignature,
|
||
framerate: f64,
|
||
) -> Option<f64> {
|
||
match self.time_display_format {
|
||
TimelineMode::Frames => Some(1.0 / framerate),
|
||
TimelineMode::Measures => {
|
||
use lightningbeam_core::beat_time::{beat_duration, measure_duration};
|
||
let beat = beat_duration(0.0, tempo_map);
|
||
let measure = measure_duration(0.0, tempo_map, time_sig);
|
||
let pps = self.pixels_per_second as f64;
|
||
// Very zoomed in: 16th note > 40px → no snap
|
||
if pps * beat / 4.0 > 40.0 { return None; }
|
||
// Find finest subdivision with >= 15px spacing (finest → coarsest)
|
||
const MIN_PX: f64 = 15.0;
|
||
for &sub in &[beat / 4.0, beat / 2.0, beat, beat * 2.0, measure] {
|
||
if pps * sub >= MIN_PX { return Some(sub); }
|
||
}
|
||
// Very zoomed out: try 2x, 4x, ... multiples of a measure
|
||
let mut m = measure * 2.0;
|
||
for _ in 0..10 {
|
||
if pps * m >= MIN_PX { return Some(m); }
|
||
m *= 2.0;
|
||
}
|
||
Some(measure)
|
||
}
|
||
TimelineMode::Seconds => None,
|
||
}
|
||
}
|
||
|
||
/// Snap a time value to the nearest quantization grid point (or return unchanged).
|
||
fn snap_to_grid(
|
||
&self,
|
||
t: f64,
|
||
tempo_map: &daw_backend::TempoMap,
|
||
time_sig: &lightningbeam_core::document::TimeSignature,
|
||
framerate: f64,
|
||
) -> f64 {
|
||
match self.quantize_grid_size(tempo_map, time_sig, framerate) {
|
||
Some(grid) => (t / grid).round() * grid,
|
||
None => t,
|
||
}
|
||
}
|
||
|
||
/// Effective drag offset for Move operations.
|
||
/// Snaps the anchor clip's resulting position to the grid; all selected clips use the same offset.
|
||
fn snapped_move_offset(
|
||
&self,
|
||
tempo_map: &daw_backend::TempoMap,
|
||
time_sig: &lightningbeam_core::document::TimeSignature,
|
||
framerate: f64,
|
||
) -> f64 {
|
||
match self.quantize_grid_size(tempo_map, time_sig, framerate) {
|
||
Some(grid) => {
|
||
let snapped = ((self.drag_anchor_start + self.drag_offset) / grid).round() * grid;
|
||
snapped - self.drag_anchor_start
|
||
}
|
||
None => self.drag_offset,
|
||
}
|
||
}
|
||
|
||
/// Calculate appropriate interval for time ruler based on zoom level
|
||
fn calculate_ruler_interval(&self) -> f64 {
|
||
// Target: 50-100px between major ticks
|
||
let target_px = 75.0;
|
||
let target_seconds = target_px / self.pixels_per_second;
|
||
|
||
// Standard intervals: 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 100...
|
||
let intervals = [0.1, 0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0, 100.0];
|
||
|
||
// Find the interval closest to our target
|
||
intervals.iter()
|
||
.min_by_key(|&&interval| ((interval - target_seconds as f64).abs() * 1000.0) as i32)
|
||
.copied()
|
||
.unwrap_or(1.0)
|
||
}
|
||
|
||
/// Calculate appropriate interval for frames ruler based on zoom level
|
||
fn calculate_ruler_interval_frames(&self, framerate: f64) -> i64 {
|
||
let target_px = 75.0;
|
||
let px_per_frame = self.pixels_per_second / framerate as f32;
|
||
let target_frames = (target_px / px_per_frame).round() as i64;
|
||
let intervals = [1i64, 2, 5, 10, 20, 50, 100, 200, 500, 1000];
|
||
intervals.iter()
|
||
.min_by_key(|&&i| (i - target_frames).abs())
|
||
.copied()
|
||
.unwrap_or(1)
|
||
}
|
||
|
||
/// Render the time ruler at the top
|
||
fn render_ruler(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme,
|
||
tempo_map: &daw_backend::TempoMap, time_sig: &lightningbeam_core::document::TimeSignature, framerate: f64) {
|
||
let painter = ui.painter();
|
||
|
||
// Background
|
||
let bg_style = theme.style(".timeline-background", ui.ctx());
|
||
let bg_color = bg_style.background_color().unwrap_or(egui::Color32::from_rgb(34, 34, 34));
|
||
painter.rect_filled(rect, 0.0, bg_color);
|
||
|
||
let text_style = theme.style(".text-primary", ui.ctx());
|
||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||
|
||
match self.time_display_format {
|
||
TimelineMode::Seconds => {
|
||
let interval = self.calculate_ruler_interval();
|
||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||
let end_time = self.x_to_time(rect.width());
|
||
|
||
let mut time = start_time;
|
||
while time <= end_time {
|
||
let x = self.time_to_x(time);
|
||
if x >= 0.0 && x <= rect.width() {
|
||
painter.line_segment(
|
||
[rect.min + egui::vec2(x, rect.height() - 10.0),
|
||
rect.min + egui::vec2(x, rect.height())],
|
||
egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick"], ui.ctx(), egui::Color32::from_gray(100))),
|
||
);
|
||
painter.text(
|
||
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||
format!("{:.1}s", time), egui::FontId::proportional(12.0), text_color,
|
||
);
|
||
}
|
||
let minor_interval = interval / 5.0;
|
||
for i in 1..5 {
|
||
let minor_x = self.time_to_x(time + minor_interval * i as f64);
|
||
if minor_x >= 0.0 && minor_x <= rect.width() {
|
||
painter.line_segment(
|
||
[rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
||
rect.min + egui::vec2(minor_x, rect.height())],
|
||
egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick-minor"], ui.ctx(), egui::Color32::from_gray(60))),
|
||
);
|
||
}
|
||
}
|
||
time += interval;
|
||
}
|
||
}
|
||
TimelineMode::Measures => {
|
||
let bpm_count = time_sig.numerator;
|
||
|
||
// Find beat range from viewport using the actual tempo map
|
||
let viewport_end_secs = self.viewport_start_time + (rect.width() / self.pixels_per_second) as f64;
|
||
let start_beat = tempo_map.inverse_transform(self.viewport_start_time.max(0.0)).floor() as i64;
|
||
let end_beat = tempo_map.inverse_transform(viewport_end_secs).ceil() as i64 + 1;
|
||
|
||
// Estimate px_per_beat for adaptive labeling (sample two adjacent beats near the start)
|
||
let ref_beat = start_beat.max(0) as f64;
|
||
let px_per_beat = (self.time_to_x(tempo_map.transform(ref_beat + 1.0))
|
||
- self.time_to_x(tempo_map.transform(ref_beat))).abs();
|
||
let measure_px = px_per_beat * bpm_count as f32;
|
||
let label_every = if measure_px > 60.0 { 1u32 } else if measure_px > 20.0 { 4 } else { 16 };
|
||
|
||
for beat_idx in start_beat..=end_beat {
|
||
if beat_idx < 0 { continue; }
|
||
let x = self.time_to_x(tempo_map.transform(beat_idx as f64));
|
||
if x < 0.0 || x > rect.width() { continue; }
|
||
|
||
let beat_in_measure = (beat_idx as u32) % bpm_count;
|
||
let measure = (beat_idx as u32) / bpm_count + 1;
|
||
let is_measure_boundary = beat_in_measure == 0;
|
||
|
||
// Tick height, stroke width, and brightness based on beat importance
|
||
let (tick_h, stroke_w, gray) = if is_measure_boundary {
|
||
(12.0, 2.0, 140u8)
|
||
} else if beat_in_measure % 2 == 0 {
|
||
(8.0, 1.0, 80)
|
||
} else {
|
||
(5.0, 1.0, 50)
|
||
};
|
||
|
||
painter.line_segment(
|
||
[rect.min + egui::vec2(x, rect.height() - tick_h),
|
||
rect.min + egui::vec2(x, rect.height())],
|
||
egui::Stroke::new(stroke_w, egui::Color32::from_gray(gray)),
|
||
);
|
||
|
||
// Labels: measure numbers at boundaries, beat numbers when zoomed in
|
||
if is_measure_boundary && (label_every == 1 || measure % label_every == 1) {
|
||
painter.text(
|
||
rect.min + egui::vec2(x + 3.0, 3.0), egui::Align2::LEFT_TOP,
|
||
format!("{}", measure), egui::FontId::proportional(12.0), text_color,
|
||
);
|
||
} else if !is_measure_boundary && px_per_beat > 40.0 {
|
||
let alpha = if beat_in_measure % 2 == 0 { 0.5 } else if px_per_beat > 80.0 { 0.25 } else { continue };
|
||
painter.text(
|
||
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||
format!("{}.{}", measure, beat_in_measure + 1),
|
||
egui::FontId::proportional(10.0), text_color.gamma_multiply(alpha),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
TimelineMode::Frames => {
|
||
let interval = self.calculate_ruler_interval_frames(framerate);
|
||
let start_frame = (self.viewport_start_time.max(0.0) * framerate).floor() as i64;
|
||
let end_frame = (self.x_to_time(rect.width()) * framerate).ceil() as i64;
|
||
// Align so labels fall on display multiples of interval (5, 10, 15...)
|
||
let start_frame = ((start_frame + interval) / interval) * interval - 1;
|
||
|
||
let mut frame = start_frame;
|
||
while frame <= end_frame {
|
||
let x = self.time_to_x(frame as f64 / framerate);
|
||
if x >= 0.0 && x <= rect.width() {
|
||
painter.line_segment(
|
||
[rect.min + egui::vec2(x, rect.height() - 10.0),
|
||
rect.min + egui::vec2(x, rect.height())],
|
||
egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick"], ui.ctx(), egui::Color32::from_gray(100))),
|
||
);
|
||
painter.text(
|
||
rect.min + egui::vec2(x + 2.0, 5.0), egui::Align2::LEFT_TOP,
|
||
format!("{}", frame + 1),
|
||
egui::FontId::proportional(12.0), text_color,
|
||
);
|
||
}
|
||
let sub = interval / 5;
|
||
if sub >= 1 {
|
||
for i in 1..5i64 {
|
||
let minor_x = self.time_to_x((frame + sub * i) as f64 / framerate);
|
||
if minor_x >= 0.0 && minor_x <= rect.width() {
|
||
painter.line_segment(
|
||
[rect.min + egui::vec2(minor_x, rect.height() - 5.0),
|
||
rect.min + egui::vec2(minor_x, rect.height())],
|
||
egui::Stroke::new(1.0, theme.text_color(&["#timeline", ".ruler-tick-minor"], ui.ctx(), egui::Color32::from_gray(60))),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
frame += interval;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Render the playhead (current time indicator)
|
||
fn render_playhead(&self, ui: &mut egui::Ui, rect: egui::Rect, theme: &crate::theme::Theme, playback_time: f64) {
|
||
let x = self.time_to_x(playback_time);
|
||
|
||
if x >= 0.0 && x <= rect.width() {
|
||
let painter = ui.painter();
|
||
let scrubber_style = theme.style(".timeline-scrubber", ui.ctx());
|
||
let scrubber_color = scrubber_style.background_color().unwrap_or(egui::Color32::from_rgb(204, 34, 34));
|
||
|
||
// Red vertical line
|
||
painter.line_segment(
|
||
[
|
||
rect.min + egui::vec2(x, 0.0),
|
||
egui::pos2(rect.min.x + x, rect.max.y),
|
||
],
|
||
egui::Stroke::new(2.0, scrubber_color),
|
||
);
|
||
|
||
// Playhead handle (triangle at top)
|
||
let handle_size = 8.0;
|
||
let points = vec![
|
||
rect.min + egui::vec2(x, 0.0),
|
||
rect.min + egui::vec2(x - handle_size / 2.0, handle_size),
|
||
rect.min + egui::vec2(x + handle_size / 2.0, handle_size),
|
||
];
|
||
painter.add(egui::Shape::convex_polygon(
|
||
points,
|
||
scrubber_color,
|
||
egui::Stroke::NONE,
|
||
));
|
||
}
|
||
}
|
||
|
||
/// Render mini piano roll visualization for MIDI clips on timeline
|
||
/// Shows notes modulo 12 (one octave) matching the JavaScript reference implementation
|
||
///
|
||
/// `display_bpm`: when `Some(bpm)`, note timestamps are derived from `timestamp_beats`
|
||
/// so positions stay beat-anchored during live BPM drag preview.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn render_midi_piano_roll(
|
||
painter: &egui::Painter,
|
||
clip_rect: egui::Rect,
|
||
rect_min_x: f32, // Timeline panel left edge (for proper viewport-relative positioning)
|
||
events: &[daw_backend::audio::midi::MidiEvent],
|
||
trim_start: f64,
|
||
visible_duration: f64,
|
||
timeline_start: f64,
|
||
viewport_start_time: f64,
|
||
pixels_per_second: f32,
|
||
theme: &crate::theme::Theme,
|
||
ctx: &egui::Context,
|
||
faded: bool,
|
||
tempo_map: &daw_backend::TempoMap,
|
||
) {
|
||
let clip_height = clip_rect.height();
|
||
let note_height = clip_height / 12.0; // 12 semitones per octave
|
||
|
||
// Get note color from theme CSS (fallback to black)
|
||
let note_style = theme.style(".timeline-midi-note", ctx);
|
||
let note_color = note_style.background_color().unwrap_or(egui::Color32::BLACK);
|
||
|
||
// `timestamp` is in beats; convert to display-seconds using the full tempo map.
|
||
let event_display_time = |ev: &daw_backend::audio::midi::MidiEvent| -> f64 {
|
||
tempo_map.transform(ev.timestamp.beats_to_f64())
|
||
};
|
||
|
||
// Build a map of active notes (note_number -> note_on_timestamp)
|
||
// to calculate durations when we encounter note-offs
|
||
let mut active_notes: std::collections::HashMap<u8, f64> = std::collections::HashMap::new();
|
||
let mut note_rectangles: Vec<(egui::Rect, u8)> = Vec::new();
|
||
|
||
// First pass: pair note-ons with note-offs to calculate durations
|
||
for event in events {
|
||
if event.is_note_on() {
|
||
let (note_number, timestamp) = (event.data1, event_display_time(event));
|
||
active_notes.insert(note_number, timestamp);
|
||
} else if event.is_note_off() {
|
||
let (note_number, timestamp) = (event.data1, event_display_time(event));
|
||
if let Some(¬e_on_time) = active_notes.get(¬e_number) {
|
||
let duration = timestamp - note_on_time;
|
||
|
||
// Skip notes outside visible trim range
|
||
if note_on_time < trim_start || note_on_time > trim_start + visible_duration {
|
||
active_notes.remove(¬e_number);
|
||
continue;
|
||
}
|
||
|
||
// Calculate X position and width
|
||
// Convert note position to absolute timeline position
|
||
let note_timeline_pos = timeline_start + (note_on_time - trim_start);
|
||
// Convert to screen X using same formula as clip positioning (time_to_x)
|
||
let note_x = rect_min_x + ((note_timeline_pos - viewport_start_time) * pixels_per_second as f64) as f32;
|
||
|
||
// Calculate note width from duration (minimum 2px for visibility)
|
||
let note_width = (duration as f32 * pixels_per_second).max(2.0);
|
||
|
||
// Calculate Y position (modulo 12 for octave wrapping)
|
||
let pitch_class = note_number % 12;
|
||
let note_y = clip_rect.min.y + ((11 - pitch_class) as f32 * note_height);
|
||
|
||
let note_rect = egui::Rect::from_min_size(
|
||
egui::pos2(note_x, note_y),
|
||
egui::vec2(note_width, note_height - 1.0), // -1 for spacing between notes
|
||
);
|
||
|
||
// Store for rendering (only if visible)
|
||
if note_rect.right() >= clip_rect.left() && note_rect.left() <= clip_rect.right() {
|
||
note_rectangles.push((note_rect, note_number));
|
||
}
|
||
|
||
active_notes.remove(¬e_number);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle any notes that didn't get a note-off (still active at end of clip)
|
||
for (¬e_number, ¬e_on_time) in &active_notes {
|
||
// Skip notes outside visible trim range
|
||
if note_on_time < trim_start || note_on_time > trim_start + visible_duration {
|
||
continue;
|
||
}
|
||
|
||
// Use a default duration (extend to end of visible area or 0.5 seconds, whichever is shorter)
|
||
let max_end_time = (trim_start + visible_duration).min(note_on_time + 0.5);
|
||
let duration = max_end_time - note_on_time;
|
||
|
||
// Convert note position to absolute timeline position
|
||
let note_timeline_pos = timeline_start + (note_on_time - trim_start);
|
||
// Convert to screen X using same formula as clip positioning (time_to_x)
|
||
let note_x = rect_min_x + ((note_timeline_pos - viewport_start_time) * pixels_per_second as f64) as f32;
|
||
|
||
let note_width = (duration as f32 * pixels_per_second).max(2.0);
|
||
|
||
let pitch_class = note_number % 12;
|
||
let note_y = clip_rect.min.y + ((11 - pitch_class) as f32 * note_height);
|
||
|
||
let note_rect = egui::Rect::from_min_size(
|
||
egui::pos2(note_x, note_y),
|
||
egui::vec2(note_width, note_height - 1.0),
|
||
);
|
||
|
||
if note_rect.right() >= clip_rect.left() && note_rect.left() <= clip_rect.right() {
|
||
note_rectangles.push((note_rect, note_number));
|
||
}
|
||
}
|
||
|
||
// Second pass: render all note rectangles
|
||
let render_color = if faded {
|
||
egui::Color32::from_rgba_unmultiplied(note_color.r(), note_color.g(), note_color.b(), note_color.a() / 2)
|
||
} else {
|
||
note_color
|
||
};
|
||
for (note_rect, _note_number) in note_rectangles {
|
||
painter.rect_filled(note_rect, 1.0, render_color);
|
||
}
|
||
}
|
||
|
||
/// Render layer header column (left side with track names and controls)
|
||
fn render_layer_headers(
|
||
&mut self,
|
||
ui: &mut egui::Ui,
|
||
rect: egui::Rect,
|
||
theme: &crate::theme::Theme,
|
||
active_layer_id: &Option<uuid::Uuid>,
|
||
focus: &lightningbeam_core::selection::FocusSelection,
|
||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||
document: &lightningbeam_core::document::Document,
|
||
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
||
layer_to_track_map: &std::collections::HashMap<uuid::Uuid, daw_backend::TrackId>,
|
||
track_levels: &std::collections::HashMap<daw_backend::TrackId, f32>,
|
||
input_level: f32,
|
||
playback_time: f64,
|
||
) {
|
||
// Background for header column
|
||
let header_style = theme.style(".timeline-header", ui.ctx());
|
||
let header_bg = header_style.background_color().unwrap_or(egui::Color32::from_rgb(17, 17, 17));
|
||
ui.painter().rect_filled(
|
||
rect,
|
||
0.0,
|
||
header_bg,
|
||
);
|
||
|
||
// Theme colors for active/inactive layers
|
||
let active_style = theme.style(".timeline-layer-active", ui.ctx());
|
||
let inactive_style = theme.style(".timeline-layer-inactive", ui.ctx());
|
||
let active_color = active_style.background_color().unwrap_or(egui::Color32::from_rgb(79, 79, 79));
|
||
let inactive_color = inactive_style.background_color().unwrap_or(egui::Color32::from_rgb(51, 51, 51));
|
||
|
||
// Get text color from theme
|
||
let text_style = theme.style(".text-primary", ui.ctx());
|
||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||
let secondary_style = theme.style(".text-secondary", ui.ctx());
|
||
let secondary_text_color = secondary_style.text_color.unwrap_or(egui::Color32::from_gray(150));
|
||
|
||
// Build virtual row list (accounts for group expansion)
|
||
let all_rows = build_timeline_rows(context_layers);
|
||
|
||
// When dragging layers, filter them out and compute gap-adjusted positions
|
||
let drag_layer_ids: Vec<uuid::Uuid> = self.layer_drag.as_ref()
|
||
.map(|d| d.layer_ids.clone()).unwrap_or_default();
|
||
let drag_count = drag_layer_ids.len();
|
||
let gap_row_index = self.layer_drag.as_ref().map(|d| d.gap_row_index);
|
||
|
||
// Build filtered row list (excluding dragged layers)
|
||
let rows: Vec<TimelineRow> = all_rows.iter()
|
||
.filter(|r| !drag_layer_ids.contains(&r.layer_id()))
|
||
.copied()
|
||
.collect();
|
||
|
||
// Draw layer headers from virtual row list
|
||
for (filtered_i, row) in rows.iter().enumerate() {
|
||
// Compute Y using cumulative heights (supports variable-height rows with automation lanes)
|
||
let base_y = self.cumulative_row_y(&rows, filtered_i);
|
||
let gap_shift = match gap_row_index {
|
||
Some(gap) if filtered_i >= gap => drag_count as f32 * LAYER_HEIGHT,
|
||
_ => 0.0,
|
||
};
|
||
let y = rect.min.y + base_y + gap_shift - self.viewport_scroll_y;
|
||
let row_total_height = self.row_height(row);
|
||
|
||
// Skip if row is outside visible area
|
||
if y + row_total_height < rect.min.y || y > rect.max.y {
|
||
continue;
|
||
}
|
||
|
||
// Indent for group children and collapsed groups based on depth
|
||
let indent = match row {
|
||
TimelineRow::GroupChild { depth, .. } => *depth as f32 * 16.0,
|
||
TimelineRow::CollapsedGroup { depth, .. } => *depth as f32 * 16.0,
|
||
_ => 0.0,
|
||
};
|
||
|
||
let header_rect = egui::Rect::from_min_size(
|
||
egui::pos2(rect.min.x, y),
|
||
egui::vec2(LAYER_HEADER_WIDTH, LAYER_HEIGHT),
|
||
);
|
||
|
||
// Determine the AnyLayer or GroupLayer for this row
|
||
let (layer_id, layer_name, layer_type, type_color) = match row {
|
||
TimelineRow::Normal(layer) => {
|
||
let data = layer.layer();
|
||
let (lt, tc) = layer_type_info(layer);
|
||
(layer.id(), data.name.clone(), lt, tc)
|
||
}
|
||
TimelineRow::CollapsedGroup { group, .. } => {
|
||
(group.layer.id, group.layer.name.clone(), "Group", theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180)))
|
||
}
|
||
TimelineRow::GroupChild { child, .. } => {
|
||
let data = child.layer();
|
||
let (lt, tc) = layer_type_info(child);
|
||
(child.id(), data.name.clone(), lt, tc)
|
||
}
|
||
};
|
||
|
||
// Active vs inactive background colors
|
||
let is_active = active_layer_id.map_or(false, |id| id == layer_id);
|
||
let is_selected = match focus {
|
||
lightningbeam_core::selection::FocusSelection::Layers(ids) => ids.contains(&layer_id),
|
||
_ => false,
|
||
};
|
||
let bg_color = if is_active || is_selected {
|
||
active_color
|
||
} else {
|
||
inactive_color
|
||
};
|
||
|
||
ui.painter().rect_filled(header_rect, 0.0, bg_color);
|
||
|
||
// Gutter area (left of indicator) — solid group color, with collapse chevron
|
||
if indent > 0.0 {
|
||
let gutter_rect = egui::Rect::from_min_size(
|
||
header_rect.min,
|
||
egui::vec2(indent, LAYER_HEIGHT),
|
||
);
|
||
// Solid dark group color for the gutter strip
|
||
let group_color = match row {
|
||
TimelineRow::GroupChild { .. } | TimelineRow::CollapsedGroup { .. } => {
|
||
// Solid dark teal for the group gutter
|
||
theme.bg_color(&["#timeline", ".group-gutter"], ui.ctx(), egui::Color32::from_rgb(0, 50, 50))
|
||
}
|
||
_ => header_bg,
|
||
};
|
||
ui.painter().rect_filled(gutter_rect, 0.0, group_color);
|
||
|
||
// Thin colored accent line at right edge of gutter (group color)
|
||
let accent_rect = egui::Rect::from_min_size(
|
||
egui::pos2(header_rect.min.x + indent - 2.0, y),
|
||
egui::vec2(2.0, LAYER_HEIGHT),
|
||
);
|
||
ui.painter().rect_filled(accent_rect, 0.0, theme.bg_color(&["#timeline", ".group-accent"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180)));
|
||
|
||
// Draw collapse triangle on first child row (painted, not text)
|
||
if let TimelineRow::GroupChild { show_collapse: true, .. } = row {
|
||
let cx = header_rect.min.x + indent * 0.5;
|
||
let cy = y + LAYER_HEIGHT * 0.5;
|
||
let s = 4.0; // half-size of triangle
|
||
// Down-pointing triangle (▼) for collapse
|
||
let tri = vec![
|
||
egui::pos2(cx - s, cy - s * 0.6),
|
||
egui::pos2(cx + s, cy - s * 0.6),
|
||
egui::pos2(cx, cy + s * 0.6),
|
||
];
|
||
ui.painter().add(egui::Shape::convex_polygon(tri, theme.text_color(&["#timeline", ".collapse-triangle"], ui.ctx(), egui::Color32::from_gray(180)), egui::Stroke::NONE));
|
||
}
|
||
|
||
// Make the ENTIRE gutter clickable for collapse on any GroupChild row
|
||
if let TimelineRow::GroupChild { group, .. } = row {
|
||
let gutter_response = ui.scope_builder(egui::UiBuilder::new().max_rect(gutter_rect), |ui| {
|
||
ui.allocate_rect(gutter_rect, egui::Sense::click())
|
||
}).inner;
|
||
if gutter_response.clicked() {
|
||
self.layer_control_clicked = true;
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::ToggleGroupExpansionAction::new(group.layer.id, false),
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Color indicator bar on the left edge (after gutter)
|
||
let indicator_rect = egui::Rect::from_min_size(
|
||
header_rect.min + egui::vec2(indent, 0.0),
|
||
egui::vec2(4.0, LAYER_HEIGHT),
|
||
);
|
||
ui.painter().rect_filled(indicator_rect, 0.0, type_color);
|
||
|
||
// Expand triangle in the header for collapsed groups
|
||
let mut name_x_offset = 10.0 + indent;
|
||
if let TimelineRow::CollapsedGroup { group, .. } = row {
|
||
// Right-pointing triangle (▶) for expand, painted manually
|
||
let cx = header_rect.min.x + indent + 14.0;
|
||
let cy = y + 17.0;
|
||
let s = 4.0;
|
||
let tri = vec![
|
||
egui::pos2(cx - s * 0.6, cy - s),
|
||
egui::pos2(cx - s * 0.6, cy + s),
|
||
egui::pos2(cx + s * 0.6, cy),
|
||
];
|
||
ui.painter().add(egui::Shape::convex_polygon(tri, theme.text_color(&["#timeline", ".collapse-triangle"], ui.ctx(), egui::Color32::from_gray(180)), egui::Stroke::NONE));
|
||
|
||
// Clickable area for expand
|
||
let chevron_rect = egui::Rect::from_min_size(
|
||
egui::pos2(header_rect.min.x + indent + 4.0, y + 4.0),
|
||
egui::vec2(20.0, 24.0),
|
||
);
|
||
let chevron_response = ui.scope_builder(egui::UiBuilder::new().max_rect(chevron_rect), |ui| {
|
||
ui.allocate_rect(chevron_rect, egui::Sense::click())
|
||
}).inner;
|
||
if chevron_response.clicked() {
|
||
self.layer_control_clicked = true;
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::ToggleGroupExpansionAction::new(group.layer.id, true),
|
||
));
|
||
}
|
||
name_x_offset = 10.0 + indent + 18.0;
|
||
}
|
||
|
||
// Layer name — double-click to rename inline
|
||
let name_pos = header_rect.min + egui::vec2(name_x_offset, 4.0);
|
||
let name_rect = egui::Rect::from_min_size(
|
||
name_pos,
|
||
egui::vec2(header_rect.max.x - name_pos.x - 8.0, 22.0),
|
||
);
|
||
let is_renaming = self.renaming_layer.as_ref().map_or(false, |(id, _)| *id == layer_id);
|
||
if is_renaming {
|
||
let buf = &mut self.renaming_layer.as_mut().unwrap().1;
|
||
let te = egui::TextEdit::singleline(buf)
|
||
.font(egui::FontId::proportional(14.0))
|
||
.text_color(text_color)
|
||
.frame(false)
|
||
.desired_width(name_rect.width());
|
||
let te_resp = ui.put(name_rect, te);
|
||
te_resp.request_focus();
|
||
let done = te_resp.lost_focus()
|
||
|| ui.input(|i| i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Escape));
|
||
if done {
|
||
let new_name = self.renaming_layer.take().unwrap().1;
|
||
let new_name = new_name.trim().to_string();
|
||
if !new_name.is_empty() && new_name != layer_name {
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
lightningbeam_core::actions::LayerProperty::Name(new_name),
|
||
)
|
||
));
|
||
}
|
||
self.layer_control_clicked = true;
|
||
}
|
||
} else {
|
||
let name_resp = ui.allocate_rect(name_rect, egui::Sense::click());
|
||
ui.painter().text(
|
||
name_pos + egui::vec2(0.0, 6.0),
|
||
egui::Align2::LEFT_TOP,
|
||
&layer_name,
|
||
egui::FontId::proportional(14.0),
|
||
text_color,
|
||
);
|
||
if name_resp.double_clicked() {
|
||
self.renaming_layer = Some((layer_id, layer_name.clone()));
|
||
self.layer_control_clicked = true;
|
||
}
|
||
}
|
||
|
||
// Layer type (smaller text below name with colored background)
|
||
let type_text_pos = header_rect.min + egui::vec2(name_x_offset, 28.0);
|
||
let type_text_galley = ui.painter().layout_no_wrap(
|
||
layer_type.to_string(),
|
||
egui::FontId::proportional(11.0),
|
||
secondary_text_color,
|
||
);
|
||
|
||
// Draw colored background for type label
|
||
let type_bg_rect = egui::Rect::from_min_size(
|
||
type_text_pos + egui::vec2(-2.0, -1.0),
|
||
egui::vec2(type_text_galley.size().x + 4.0, type_text_galley.size().y + 2.0),
|
||
);
|
||
ui.painter().rect_filled(
|
||
type_bg_rect,
|
||
2.0,
|
||
egui::Color32::from_rgba_unmultiplied(type_color.r(), type_color.g(), type_color.b(), 60),
|
||
);
|
||
|
||
ui.painter().text(
|
||
type_text_pos,
|
||
egui::Align2::LEFT_TOP,
|
||
layer_type,
|
||
egui::FontId::proportional(11.0),
|
||
secondary_text_color,
|
||
);
|
||
|
||
// Get the AnyLayer reference for controls
|
||
let any_layer_for_controls: Option<&AnyLayer> = match row {
|
||
TimelineRow::Normal(l) => Some(l),
|
||
TimelineRow::CollapsedGroup { group, .. } => {
|
||
// We need an AnyLayer ref - find it from context_layers
|
||
context_layers.iter().rev().copied().find(|l| l.id() == group.layer.id)
|
||
}
|
||
TimelineRow::GroupChild { child, .. } => Some(child),
|
||
};
|
||
|
||
let Some(layer_for_controls) = any_layer_for_controls else { continue; };
|
||
|
||
// Layer controls: volume slider top-right, buttons below it
|
||
let controls_right = header_rect.max.x - 8.0;
|
||
let button_size = egui::vec2(20.0, 20.0);
|
||
let slider_width = 60.0;
|
||
|
||
let volume_slider_rect = egui::Rect::from_min_size(
|
||
egui::pos2(controls_right - slider_width, header_rect.min.y + 4.0),
|
||
egui::vec2(slider_width, 20.0),
|
||
);
|
||
|
||
// Buttons sit below the slider, right-aligned to match it
|
||
let buttons_top = volume_slider_rect.max.y + 4.0;
|
||
let lock_button_rect = egui::Rect::from_min_size(
|
||
egui::pos2(controls_right - button_size.x, buttons_top),
|
||
button_size,
|
||
);
|
||
|
||
let solo_button_rect = egui::Rect::from_min_size(
|
||
egui::pos2(lock_button_rect.min.x - button_size.x - 4.0, buttons_top),
|
||
button_size,
|
||
);
|
||
|
||
let mute_button_rect = egui::Rect::from_min_size(
|
||
egui::pos2(solo_button_rect.min.x - button_size.x - 4.0, buttons_top),
|
||
button_size,
|
||
);
|
||
|
||
// Get layer ID and current property values from the layer we already have
|
||
// Check if there's a Volume automation lane; use it to drive the slider
|
||
let volume_auto_info = self.automation_cache.get(&layer_id)
|
||
.and_then(|lanes| lanes.iter().find(|l| l.name == "Volume"))
|
||
.map(|l| (l.node_id, l.keyframes.clone()));
|
||
|
||
// Classify the volume automation state
|
||
let (volume_auto_node_id, volume_auto_value, volume_is_automated) =
|
||
match &volume_auto_info {
|
||
None => (None, None, false),
|
||
Some((nid, kfs)) if kfs.len() == 1 && (kfs[0].time - 0.0).abs() < 0.001 => {
|
||
(Some(*nid), Some(kfs[0].value as f64), false)
|
||
}
|
||
Some((nid, kfs)) => {
|
||
// Multiple keyframes — slider is read-only; show the curve value at playback pos
|
||
let playback_beats = document.tempo_map().inverse_transform(playback_time);
|
||
let v = crate::curve_editor::evaluate_curve(kfs, playback_beats);
|
||
(Some(*nid), Some(v as f64), true)
|
||
}
|
||
};
|
||
|
||
let current_volume = volume_auto_value.unwrap_or_else(|| layer_for_controls.volume());
|
||
let is_muted = layer_for_controls.muted();
|
||
let is_soloed = layer_for_controls.soloed();
|
||
let is_locked = layer_for_controls.locked();
|
||
|
||
// Mute button — or camera toggle for video layers
|
||
let is_video_layer = matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Video(_));
|
||
let camera_enabled = if let lightningbeam_core::layer::AnyLayer::Video(v) = layer_for_controls {
|
||
v.camera_enabled
|
||
} else {
|
||
false
|
||
};
|
||
|
||
let first_btn_response = ui.scope_builder(egui::UiBuilder::new().max_rect(mute_button_rect), |ui| {
|
||
if is_video_layer {
|
||
// Camera toggle for video layers
|
||
let cam_text = if camera_enabled { "📹" } else { "📷" };
|
||
let button = egui::Button::new(cam_text)
|
||
.fill(if camera_enabled {
|
||
theme.bg_color(&["#timeline", ".btn-toggle", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100))
|
||
} else {
|
||
theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40))
|
||
})
|
||
.stroke(egui::Stroke::NONE);
|
||
ui.add(button)
|
||
} else {
|
||
// Mute button for non-video layers
|
||
let mute_text = if is_muted { "🔇" } else { "🔊" };
|
||
let button = egui::Button::new(mute_text)
|
||
.fill(if is_muted {
|
||
theme.bg_color(&["#timeline", ".btn-mute", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(255, 100, 100, 100))
|
||
} else {
|
||
theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40))
|
||
})
|
||
.stroke(egui::Stroke::NONE);
|
||
ui.add(button)
|
||
}
|
||
}).inner;
|
||
|
||
if first_btn_response.clicked() {
|
||
self.layer_control_clicked = true;
|
||
if is_video_layer {
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
lightningbeam_core::actions::LayerProperty::CameraEnabled(!camera_enabled),
|
||
)
|
||
));
|
||
} else {
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
lightningbeam_core::actions::LayerProperty::Muted(!is_muted),
|
||
)
|
||
));
|
||
}
|
||
}
|
||
|
||
// Solo button
|
||
// TODO: Replace with SVG headphones icon
|
||
let solo_response = ui.scope_builder(egui::UiBuilder::new().max_rect(solo_button_rect), |ui| {
|
||
let button = egui::Button::new("🎧")
|
||
.fill(if is_soloed {
|
||
theme.bg_color(&["#timeline", ".btn-solo", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(100, 200, 100, 100))
|
||
} else {
|
||
theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40))
|
||
})
|
||
.stroke(egui::Stroke::NONE);
|
||
ui.add(button)
|
||
}).inner;
|
||
|
||
if solo_response.clicked() {
|
||
self.layer_control_clicked = true;
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
lightningbeam_core::actions::LayerProperty::Soloed(!is_soloed),
|
||
)
|
||
));
|
||
}
|
||
|
||
// Lock button
|
||
// TODO: Replace with SVG lock/lock-open icons
|
||
let lock_response = ui.scope_builder(egui::UiBuilder::new().max_rect(lock_button_rect), |ui| {
|
||
let lock_text = if is_locked { "🔒" } else { "🔓" };
|
||
let button = egui::Button::new(lock_text)
|
||
.fill(if is_locked {
|
||
theme.bg_color(&["#timeline", ".btn-lock", ".active"], ui.ctx(), egui::Color32::from_rgba_unmultiplied(200, 150, 100, 100))
|
||
} else {
|
||
theme.bg_color(&["#timeline", ".btn-toggle"], ui.ctx(), egui::Color32::from_gray(40))
|
||
})
|
||
.stroke(egui::Stroke::NONE);
|
||
ui.add(button)
|
||
}).inner;
|
||
|
||
if lock_response.clicked() {
|
||
self.layer_control_clicked = true;
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
lightningbeam_core::actions::LayerProperty::Locked(!is_locked),
|
||
)
|
||
));
|
||
}
|
||
|
||
// Volume slider (nonlinear: 0-70% slider = 0-100% volume, 70-100% slider = 100-200% volume)
|
||
// Disabled when the user has edited the Volume automation curve beyond the default single keyframe
|
||
let volume_response = ui.scope_builder(egui::UiBuilder::new().max_rect(volume_slider_rect), |ui| {
|
||
ui.spacing_mut().slider_width = slider_width;
|
||
// Map volume (0.0-2.0) to slider position (0.0-1.0)
|
||
let slider_value = if current_volume <= 1.0 {
|
||
// 0.0-1.0 volume maps to 0.0-0.7 slider (70%)
|
||
current_volume * 0.7
|
||
} else {
|
||
// 1.0-2.0 volume maps to 0.7-1.0 slider (30%)
|
||
0.7 + (current_volume - 1.0) * 0.3
|
||
};
|
||
|
||
let mut temp_slider_value = slider_value as f32;
|
||
let slider = egui::Slider::new(&mut temp_slider_value, 0.0..=1.0)
|
||
.show_value(false);
|
||
|
||
let response = ui.add_enabled(!volume_is_automated, slider);
|
||
(response, temp_slider_value)
|
||
}).inner;
|
||
|
||
// Block layer drag while interacting with the slider
|
||
if volume_response.0.dragged() || volume_response.0.has_focus() {
|
||
self.layer_control_clicked = true;
|
||
}
|
||
if volume_response.0.changed() && !volume_is_automated {
|
||
self.layer_control_clicked = true;
|
||
// Map slider position (0.0-1.0) back to volume (0.0-2.0)
|
||
let new_volume = if volume_response.1 <= 0.7 {
|
||
// 0.0-0.7 slider maps to 0.0-1.0 volume
|
||
volume_response.1 / 0.7
|
||
} else {
|
||
// 0.7-1.0 slider maps to 1.0-2.0 volume
|
||
1.0 + (volume_response.1 - 0.7) / 0.3
|
||
};
|
||
|
||
if let Some(node_id) = volume_auto_node_id {
|
||
// Route through automation: update the t=0 keyframe
|
||
// Ensure range is 0–2 so the curve editor shows the correct y-axis
|
||
let needs_range_update = self.automation_cache.get(&layer_id)
|
||
.and_then(|lanes| lanes.iter().find(|l| l.node_id == node_id))
|
||
.map(|l| (l.value_min - 0.0).abs() > 0.01 || (l.value_max - 2.0).abs() > 0.01)
|
||
.unwrap_or(true);
|
||
if needs_range_update {
|
||
self.pending_automation_actions.push(AutomationLaneAction::SetRange {
|
||
layer_id, node_id, min: 0.0, max: 2.0,
|
||
});
|
||
}
|
||
self.pending_automation_actions.push(AutomationLaneAction::AddKeyframe {
|
||
layer_id,
|
||
node_id,
|
||
time: 0.0,
|
||
value: new_volume,
|
||
});
|
||
// Optimistic cache update so slider feels immediate
|
||
if let Some(lanes) = self.automation_cache.get_mut(&layer_id) {
|
||
if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) {
|
||
lane.value_min = 0.0;
|
||
lane.value_max = 2.0;
|
||
if let Some(kf) = lane.keyframes.iter_mut().find(|k| k.time < 0.001) {
|
||
kf.value = new_volume;
|
||
}
|
||
}
|
||
}
|
||
} else {
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
lightningbeam_core::actions::LayerProperty::Volume(new_volume as f64),
|
||
)
|
||
));
|
||
}
|
||
}
|
||
|
||
// Input gain slider for sampled audio layers (below volume slider)
|
||
if let lightningbeam_core::layer::AnyLayer::Audio(audio_layer) = layer_for_controls {
|
||
if audio_layer.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled {
|
||
let gain_slider_rect = egui::Rect::from_min_size(
|
||
egui::pos2(controls_right - slider_width, volume_slider_rect.max.y + 4.0),
|
||
egui::vec2(slider_width, 16.0),
|
||
);
|
||
let current_gain = audio_layer.layer.input_gain;
|
||
|
||
// Map gain (0.0-4.0) to slider (0.0-1.0): linear
|
||
let mut slider_val = (current_gain / 4.0) as f32;
|
||
let gain_response = ui.scope_builder(egui::UiBuilder::new().max_rect(gain_slider_rect), |ui| {
|
||
let slider = egui::Slider::new(&mut slider_val, 0.0..=1.0f32)
|
||
.show_value(false);
|
||
ui.add(slider)
|
||
}).inner;
|
||
|
||
// Block layer drag while interacting with the slider
|
||
if gain_response.dragged() || gain_response.has_focus() {
|
||
self.layer_control_clicked = true;
|
||
}
|
||
if gain_response.changed() {
|
||
self.layer_control_clicked = true;
|
||
let new_gain = (slider_val * 4.0) as f64;
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::SetLayerPropertiesAction::new(
|
||
layer_id,
|
||
lightningbeam_core::actions::LayerProperty::InputGain(new_gain),
|
||
)
|
||
));
|
||
}
|
||
|
||
// Label
|
||
let label_rect = egui::Rect::from_min_size(
|
||
egui::pos2(gain_slider_rect.min.x - 26.0, volume_slider_rect.max.y + 4.0),
|
||
egui::vec2(24.0, 16.0),
|
||
);
|
||
ui.painter().text(
|
||
label_rect.center(),
|
||
egui::Align2::CENTER_CENTER,
|
||
"Gain",
|
||
egui::FontId::proportional(9.0),
|
||
theme.text_color(&["#timeline", ".gain-label"], ui.ctx(), egui::Color32::from_gray(140)),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Per-layer VU meter bar (4px tall at bottom of header)
|
||
{
|
||
// Look up the track level for this layer
|
||
let mut level = 0.0f32;
|
||
if let Some(&track_id) = layer_to_track_map.get(&layer_id) {
|
||
if let Some(&track_level) = track_levels.get(&track_id) {
|
||
level = track_level;
|
||
}
|
||
}
|
||
|
||
// For active sampled audio layer, show max of track level and input level
|
||
let is_active_sampled_audio = active_layer_id.map_or(false, |id| id == layer_id)
|
||
&& matches!(layer_for_controls, lightningbeam_core::layer::AnyLayer::Audio(a) if a.audio_layer_type == lightningbeam_core::layer::AudioLayerType::Sampled);
|
||
if is_active_sampled_audio {
|
||
level = level.max(input_level);
|
||
}
|
||
|
||
if level > 0.001 {
|
||
let meter_height = 4.0;
|
||
let meter_rect = egui::Rect::from_min_size(
|
||
egui::pos2(header_rect.min.x, header_rect.max.y - meter_height - 1.0),
|
||
egui::vec2(header_rect.width(), meter_height),
|
||
);
|
||
let clamped = level.min(1.0);
|
||
let filled_width = meter_rect.width() * clamped;
|
||
let color = if clamped > 0.9 {
|
||
theme.bg_color(&["#timeline", ".vu-meter", ".clip"], ui.ctx(), egui::Color32::from_rgb(220, 50, 50))
|
||
} else if clamped > 0.7 {
|
||
theme.bg_color(&["#timeline", ".vu-meter", ".warn"], ui.ctx(), egui::Color32::from_rgb(220, 200, 50))
|
||
} else {
|
||
theme.bg_color(&["#timeline", ".vu-meter", ".normal"], ui.ctx(), egui::Color32::from_rgb(50, 200, 80))
|
||
};
|
||
let filled = egui::Rect::from_min_size(
|
||
meter_rect.left_top(),
|
||
egui::vec2(filled_width, meter_rect.height()),
|
||
);
|
||
ui.painter().rect_filled(filled, 0.0, color);
|
||
}
|
||
}
|
||
|
||
// Separator line at bottom
|
||
ui.painter().line_segment(
|
||
[
|
||
egui::pos2(header_rect.min.x, header_rect.max.y),
|
||
egui::pos2(header_rect.max.x, header_rect.max.y),
|
||
],
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||
);
|
||
|
||
// Automation expand/collapse button for Audio/MIDI layers and the master layer
|
||
let is_master_layer = matches!(row, TimelineRow::CollapsedGroup { group: g, .. } if g.layer.id == document.master_layer.layer.id);
|
||
let is_audio_or_midi = matches!(
|
||
row,
|
||
TimelineRow::Normal(AnyLayer::Audio(_))
|
||
| TimelineRow::GroupChild { child: AnyLayer::Audio(_), .. }
|
||
);
|
||
if is_audio_or_midi || is_master_layer {
|
||
let btn_rect = egui::Rect::from_min_size(
|
||
egui::pos2(rect.min.x + 4.0, y + LAYER_HEIGHT - 18.0),
|
||
egui::vec2(16.0, 14.0),
|
||
);
|
||
let expanded = self.automation_expanded.contains(&layer_id);
|
||
let btn_label = if expanded { "▼" } else { "▶" };
|
||
let btn_response = ui.scope_builder(egui::UiBuilder::new().max_rect(btn_rect), |ui| {
|
||
ui.allocate_rect(btn_rect, egui::Sense::click())
|
||
}).inner;
|
||
ui.painter().text(
|
||
btn_rect.center(),
|
||
egui::Align2::CENTER_CENTER,
|
||
btn_label,
|
||
egui::FontId::proportional(9.0),
|
||
secondary_text_color,
|
||
);
|
||
if btn_response.clicked() {
|
||
self.layer_control_clicked = true;
|
||
if expanded {
|
||
self.automation_expanded.remove(&layer_id);
|
||
} else {
|
||
self.automation_expanded.insert(layer_id);
|
||
// Trigger cache refresh
|
||
// We can't call refresh_automation_cache here (needs controller),
|
||
// so mark it as needing refresh via empty cache entry
|
||
self.automation_cache.remove(&layer_id);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw automation lane sub-headers below this row
|
||
if self.automation_expanded.contains(&layer_id) {
|
||
if let Some(lanes) = self.automation_cache.get(&layer_id) {
|
||
let lane_count = lanes.len();
|
||
// Collect lane info to avoid borrow conflict
|
||
let lane_names: Vec<String> = lanes.iter().map(|l| l.name.clone()).collect();
|
||
for (lane_idx, lane_name) in lane_names.iter().enumerate() {
|
||
let lane_y = y + LAYER_HEIGHT + lane_idx as f32 * AUTOMATION_LANE_HEIGHT;
|
||
if lane_y + AUTOMATION_LANE_HEIGHT < rect.min.y || lane_y > rect.max.y {
|
||
continue;
|
||
}
|
||
let lane_rect = egui::Rect::from_min_size(
|
||
egui::pos2(rect.min.x, lane_y),
|
||
egui::vec2(LAYER_HEADER_WIDTH, AUTOMATION_LANE_HEIGHT),
|
||
);
|
||
ui.painter().rect_filled(
|
||
lane_rect,
|
||
0.0,
|
||
egui::Color32::from_rgb(25, 25, 30),
|
||
);
|
||
// Indent line
|
||
let indent_rect = egui::Rect::from_min_size(
|
||
lane_rect.min,
|
||
egui::vec2(20.0, AUTOMATION_LANE_HEIGHT),
|
||
);
|
||
ui.painter().rect_filled(
|
||
indent_rect,
|
||
0.0,
|
||
egui::Color32::from_rgb(15, 15, 20),
|
||
);
|
||
// Curve icon (small ≈ symbol)
|
||
ui.painter().text(
|
||
egui::pos2(lane_rect.min.x + 10.0, lane_y + AUTOMATION_LANE_HEIGHT * 0.5),
|
||
egui::Align2::CENTER_CENTER,
|
||
"~",
|
||
egui::FontId::proportional(11.0),
|
||
secondary_text_color,
|
||
);
|
||
// Lane name
|
||
let display_name = if lane_name.is_empty() { "Automation" } else { lane_name.as_str() };
|
||
ui.painter().text(
|
||
egui::pos2(lane_rect.min.x + 22.0, lane_y + AUTOMATION_LANE_HEIGHT * 0.5 - 6.0),
|
||
egui::Align2::LEFT_TOP,
|
||
display_name,
|
||
egui::FontId::proportional(11.0),
|
||
text_color,
|
||
);
|
||
// Bottom separator
|
||
ui.painter().line_segment(
|
||
[
|
||
egui::pos2(lane_rect.min.x, lane_y + AUTOMATION_LANE_HEIGHT - 1.0),
|
||
egui::pos2(lane_rect.max.x, lane_y + AUTOMATION_LANE_HEIGHT - 1.0),
|
||
],
|
||
egui::Stroke::new(1.0, egui::Color32::from_gray(30)),
|
||
);
|
||
let _ = lane_count; // suppress warning
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw floating dragged layer headers at mouse position with drop shadow
|
||
if let Some(ref drag_state) = self.layer_drag {
|
||
// Collect the dragged rows in order
|
||
let dragged_rows: Vec<&TimelineRow> = drag_state.layer_ids.iter()
|
||
.filter_map(|did| all_rows.iter().find(|r| r.layer_id() == *did))
|
||
.collect();
|
||
|
||
let float_top_y = drag_state.current_mouse_y - drag_state.grab_offset_y;
|
||
|
||
for (di, dragged_row) in dragged_rows.iter().enumerate() {
|
||
let float_y = float_top_y + di as f32 * LAYER_HEIGHT;
|
||
let float_rect = egui::Rect::from_min_size(
|
||
egui::pos2(rect.min.x, float_y),
|
||
egui::vec2(LAYER_HEADER_WIDTH, LAYER_HEIGHT),
|
||
);
|
||
|
||
// Gradient drop shadow
|
||
paint_drop_shadow(ui.painter(), float_rect, 8.0, 60);
|
||
|
||
// Background (active/selected color)
|
||
ui.painter().rect_filled(float_rect, 0.0, active_color);
|
||
|
||
// Layer info
|
||
let drag_indent = match dragged_row {
|
||
TimelineRow::GroupChild { depth, .. } => *depth as f32 * 16.0,
|
||
TimelineRow::CollapsedGroup { depth, .. } => *depth as f32 * 16.0,
|
||
_ => 0.0,
|
||
};
|
||
let (drag_name, drag_type_str, drag_type_color) = match dragged_row {
|
||
TimelineRow::Normal(layer) => {
|
||
let (lt, tc) = layer_type_info(layer);
|
||
(layer.layer().name.clone(), lt, tc)
|
||
}
|
||
TimelineRow::CollapsedGroup { group, .. } => {
|
||
(group.layer.name.clone(), "Group", theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180)))
|
||
}
|
||
TimelineRow::GroupChild { child, .. } => {
|
||
let (lt, tc) = layer_type_info(child);
|
||
(child.layer().name.clone(), lt, tc)
|
||
}
|
||
};
|
||
|
||
// Color indicator bar
|
||
let indicator_rect = egui::Rect::from_min_size(
|
||
float_rect.min + egui::vec2(drag_indent, 0.0),
|
||
egui::vec2(4.0, LAYER_HEIGHT),
|
||
);
|
||
ui.painter().rect_filled(indicator_rect, 0.0, drag_type_color);
|
||
|
||
// Layer name
|
||
let name_x = 10.0 + drag_indent;
|
||
ui.painter().text(
|
||
float_rect.min + egui::vec2(name_x, 10.0),
|
||
egui::Align2::LEFT_TOP,
|
||
&drag_name,
|
||
egui::FontId::proportional(14.0),
|
||
text_color,
|
||
);
|
||
|
||
// Type label
|
||
ui.painter().text(
|
||
float_rect.min + egui::vec2(name_x, 28.0),
|
||
egui::Align2::LEFT_TOP,
|
||
drag_type_str,
|
||
egui::FontId::proportional(11.0),
|
||
secondary_text_color,
|
||
);
|
||
|
||
// Separator line at bottom
|
||
ui.painter().line_segment(
|
||
[egui::pos2(float_rect.min.x, float_rect.max.y), egui::pos2(float_rect.max.x, float_rect.max.y)],
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||
);
|
||
}
|
||
}
|
||
|
||
// Right border for header column
|
||
ui.painter().line_segment(
|
||
[
|
||
egui::pos2(rect.max.x, rect.min.y),
|
||
egui::pos2(rect.max.x, rect.max.y),
|
||
],
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||
);
|
||
}
|
||
|
||
/// Render layer rows (timeline content area)
|
||
/// Returns video clip hover data for processing after input handling
|
||
fn render_layers(
|
||
&mut self,
|
||
ui: &mut egui::Ui,
|
||
rect: egui::Rect,
|
||
theme: &crate::theme::Theme,
|
||
document: &lightningbeam_core::document::Document,
|
||
active_layer_id: &Option<uuid::Uuid>,
|
||
focus: &lightningbeam_core::selection::FocusSelection,
|
||
selection: &lightningbeam_core::selection::Selection,
|
||
midi_event_cache: &std::collections::HashMap<u32, Vec<daw_backend::audio::midi::MidiEvent>>,
|
||
raw_audio_cache: &std::collections::HashMap<usize, (std::sync::Arc<Vec<f32>>, u32, u32)>,
|
||
waveform_gpu_dirty: &mut std::collections::HashSet<usize>,
|
||
waveform_minmax_pools: &std::collections::HashMap<usize, u32>,
|
||
target_format: wgpu::TextureFormat,
|
||
waveform_stereo: bool,
|
||
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
||
video_manager: &std::sync::Arc<std::sync::Mutex<lightningbeam_core::video::VideoManager>>,
|
||
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
||
playback_time: f64,
|
||
) -> (Vec<(egui::Rect, uuid::Uuid, f64, f32)>, Vec<AutomationLaneRender>) {
|
||
let painter = ui.painter().clone();
|
||
let mut pending_lane_renders: Vec<AutomationLaneRender> = Vec::new();
|
||
// Rebuilt each frame; consumed by handle_input (next frame) for click-to-seek.
|
||
self.keyframe_diamond_hits.clear();
|
||
|
||
// Collect video clip rects for hover detection (to avoid borrow conflicts)
|
||
let mut video_clip_hovers: Vec<(egui::Rect, uuid::Uuid, f64, f32)> = Vec::new();
|
||
|
||
// Track visible video clip IDs for texture cache cleanup
|
||
let mut visible_video_clip_ids: std::collections::HashSet<uuid::Uuid> = std::collections::HashSet::new();
|
||
|
||
// Theme colors for active/inactive layers
|
||
let active_style = theme.style(".timeline-row-active", ui.ctx());
|
||
let inactive_style = theme.style(".timeline-row-inactive", ui.ctx());
|
||
let active_color = active_style.background_color().unwrap_or(egui::Color32::from_rgb(85, 85, 85));
|
||
let inactive_color = inactive_style.background_color().unwrap_or(egui::Color32::from_rgb(136, 136, 136));
|
||
|
||
// Build a map of clip_instance_id -> InstanceGroup for linked clip previews
|
||
let mut instance_to_group: std::collections::HashMap<uuid::Uuid, &lightningbeam_core::instance_group::InstanceGroup> = std::collections::HashMap::new();
|
||
for group in document.instance_groups.values() {
|
||
for (_, instance_id) in &group.members {
|
||
instance_to_group.insert(*instance_id, group);
|
||
}
|
||
}
|
||
|
||
// Build virtual row list (accounts for group expansion)
|
||
let all_rows = build_timeline_rows(context_layers);
|
||
|
||
// When dragging layers, compute remapped Y positions:
|
||
// - Dragged rows render at the gap position
|
||
// - Non-dragged rows shift around the gap
|
||
let drag_layer_ids_content: Vec<uuid::Uuid> = self.layer_drag.as_ref()
|
||
.map(|d| d.layer_ids.clone()).unwrap_or_default();
|
||
let drag_count_content = drag_layer_ids_content.len();
|
||
let gap_row_index_content = self.layer_drag.as_ref().map(|d| d.gap_row_index);
|
||
|
||
// Pre-compute Y position for each row.
|
||
// Dragged rows follow the mouse continuously (matching the floating header);
|
||
// non-dragged rows snap to discrete positions shifted around the gap.
|
||
let drag_float_top_y: Option<f32> = self.layer_drag.as_ref()
|
||
.map(|d| d.current_mouse_y - d.grab_offset_y);
|
||
|
||
// Pre-collect non-dragged rows for cumulative height calculation
|
||
let non_dragged_rows: Vec<TimelineRow> = all_rows.iter()
|
||
.filter(|r| !drag_layer_ids_content.contains(&r.layer_id()))
|
||
.copied()
|
||
.collect();
|
||
|
||
let row_y_positions: Vec<f32> = {
|
||
let mut positions = Vec::with_capacity(all_rows.len());
|
||
let mut filtered_i = 0usize;
|
||
let mut drag_offset = 0usize;
|
||
for row in all_rows.iter() {
|
||
if drag_layer_ids_content.contains(&row.layer_id()) {
|
||
// Dragged row: continuous Y from mouse position
|
||
let base_y = drag_float_top_y.unwrap_or(0.0);
|
||
positions.push(base_y + drag_offset as f32 * LAYER_HEIGHT);
|
||
drag_offset += 1;
|
||
} else {
|
||
// Non-dragged row: discrete position using cumulative heights
|
||
let base_y: f32 = non_dragged_rows[..filtered_i]
|
||
.iter()
|
||
.map(|r| self.row_height(r))
|
||
.sum();
|
||
let gap_shift = match gap_row_index_content {
|
||
Some(gap) if filtered_i >= gap => drag_count_content as f32 * LAYER_HEIGHT,
|
||
_ => 0.0,
|
||
};
|
||
positions.push(rect.min.y + base_y + gap_shift - self.viewport_scroll_y);
|
||
filtered_i += 1;
|
||
}
|
||
}
|
||
positions
|
||
};
|
||
|
||
// Draw non-dragged rows first, then dragged rows on top (so shadow/content overlaps correctly)
|
||
let draw_order: Vec<usize> = {
|
||
let mut non_dragged: Vec<usize> = Vec::new();
|
||
let mut dragged: Vec<usize> = Vec::new();
|
||
for (i, row) in all_rows.iter().enumerate() {
|
||
if drag_layer_ids_content.contains(&row.layer_id()) {
|
||
dragged.push(i);
|
||
} else {
|
||
non_dragged.push(i);
|
||
}
|
||
}
|
||
non_dragged.extend(dragged);
|
||
non_dragged
|
||
};
|
||
|
||
for &i in &draw_order {
|
||
let row = &all_rows[i];
|
||
let y = row_y_positions[i];
|
||
let is_being_dragged = drag_layer_ids_content.contains(&row.layer_id());
|
||
|
||
// Skip if layer is outside visible area
|
||
if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y {
|
||
continue;
|
||
}
|
||
|
||
let layer_rect = egui::Rect::from_min_size(
|
||
egui::pos2(rect.min.x, y),
|
||
egui::vec2(rect.width(), LAYER_HEIGHT),
|
||
);
|
||
|
||
// Drop shadow for dragged rows
|
||
if is_being_dragged {
|
||
paint_drop_shadow(&painter, layer_rect, 8.0, 60);
|
||
}
|
||
|
||
let row_layer_id = row.layer_id();
|
||
|
||
// Active vs inactive background colors
|
||
let is_active = active_layer_id.map_or(false, |id| id == row_layer_id);
|
||
let is_selected = match focus {
|
||
lightningbeam_core::selection::FocusSelection::Layers(ids) => ids.contains(&row_layer_id),
|
||
_ => false,
|
||
};
|
||
let bg_color = if is_active || is_selected {
|
||
active_color
|
||
} else {
|
||
inactive_color
|
||
};
|
||
|
||
painter.rect_filled(layer_rect, 0.0, bg_color);
|
||
|
||
// Grid lines matching ruler
|
||
match self.time_display_format {
|
||
TimelineMode::Seconds => {
|
||
let interval = self.calculate_ruler_interval();
|
||
let start_time = (self.viewport_start_time / interval).floor() * interval;
|
||
let end_time = self.x_to_time(rect.width());
|
||
let mut time = start_time;
|
||
while time <= end_time {
|
||
let x = self.time_to_x(time);
|
||
if x >= 0.0 && x <= rect.width() {
|
||
painter.line_segment(
|
||
[egui::pos2(rect.min.x + x, y),
|
||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".grid-line"], ui.ctx(), egui::Color32::from_gray(30))),
|
||
);
|
||
}
|
||
time += interval;
|
||
}
|
||
}
|
||
TimelineMode::Measures => {
|
||
let tm = document.tempo_map();
|
||
let bpm_count = document.time_signature.numerator;
|
||
let viewport_end_secs = self.viewport_start_time + (rect.width() / self.pixels_per_second) as f64;
|
||
let start_beat = tm.inverse_transform(self.viewport_start_time.max(0.0)).floor() as i64;
|
||
let end_beat = tm.inverse_transform(viewport_end_secs).ceil() as i64 + 1;
|
||
for beat_idx in start_beat..=end_beat {
|
||
if beat_idx < 0 { continue; }
|
||
let x = self.time_to_x(tm.transform(beat_idx as f64));
|
||
if x < 0.0 || x > rect.width() { continue; }
|
||
let is_measure_boundary = (beat_idx as u32) % bpm_count == 0;
|
||
let gray = if is_measure_boundary { 45 } else { 25 };
|
||
painter.line_segment(
|
||
[egui::pos2(rect.min.x + x, y),
|
||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||
egui::Stroke::new(if is_measure_boundary { 1.5 } else { 1.0 }, theme.border_color(&["#timeline", ".grid-line"], ui.ctx(), egui::Color32::from_gray(gray))),
|
||
);
|
||
}
|
||
}
|
||
TimelineMode::Frames => {
|
||
let framerate = document.framerate;
|
||
let px_per_frame = self.pixels_per_second / framerate as f32;
|
||
|
||
// Per-frame column shading when frames are wide enough to see
|
||
if px_per_frame >= 3.0 {
|
||
let shade_color = egui::Color32::from_rgba_unmultiplied(255, 255, 255, 8);
|
||
let start_frame = (self.viewport_start_time.max(0.0) * framerate).floor() as i64;
|
||
let end_frame = (self.x_to_time(rect.width()) * framerate).ceil() as i64;
|
||
for frame in start_frame..=end_frame {
|
||
if (frame + 1) % 5 == 0 {
|
||
let x0 = self.time_to_x(frame as f64 / framerate);
|
||
let x1 = self.time_to_x((frame + 1) as f64 / framerate);
|
||
if x1 >= 0.0 && x0 <= rect.width() {
|
||
let x0 = x0.max(0.0);
|
||
let x1 = x1.min(rect.width());
|
||
painter.rect_filled(
|
||
egui::Rect::from_min_max(
|
||
egui::pos2(rect.min.x + x0, y),
|
||
egui::pos2(rect.min.x + x1, y + LAYER_HEIGHT),
|
||
),
|
||
0.0,
|
||
shade_color,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Grid lines at ruler interval
|
||
let interval = self.calculate_ruler_interval_frames(framerate);
|
||
let start_frame = (self.viewport_start_time.max(0.0) * framerate).floor() as i64;
|
||
let end_frame = (self.x_to_time(rect.width()) * framerate).ceil() as i64;
|
||
// Align so grid lines fall on display multiples of interval (5, 10, 15...)
|
||
let start_frame = ((start_frame + interval) / interval) * interval - 1;
|
||
let mut frame = start_frame;
|
||
while frame <= end_frame {
|
||
let x = self.time_to_x(frame as f64 / framerate);
|
||
if x >= 0.0 && x <= rect.width() {
|
||
painter.line_segment(
|
||
[egui::pos2(rect.min.x + x, y),
|
||
egui::pos2(rect.min.x + x, y + LAYER_HEIGHT)],
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".grid-line"], ui.ctx(), egui::Color32::from_gray(30))),
|
||
);
|
||
}
|
||
frame += interval;
|
||
}
|
||
}
|
||
}
|
||
|
||
// For collapsed groups, render merged clip spans and skip normal clip rendering
|
||
if let TimelineRow::CollapsedGroup { group: g, .. } = row {
|
||
// Collect all child clip time ranges (with drag preview offset)
|
||
let child_clips = g.all_child_clip_instances();
|
||
let is_move_drag = self.clip_drag_state == Some(ClipDragType::Move);
|
||
let mut ranges: Vec<(f64, f64)> = Vec::new();
|
||
for (_child_layer_id, ci) in &child_clips {
|
||
let clip_dur = document.get_clip_duration(&ci.clip_id).unwrap_or_else(|| {
|
||
ci.trim_end.unwrap_or(1.0) - ci.trim_start
|
||
});
|
||
let mut start = ci.effective_start();
|
||
let dur = ci.total_duration(clip_dur, document.tempo_map());
|
||
// Apply drag offset for selected clips during move
|
||
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
||
start = (start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
|
||
}
|
||
ranges.push((start, start + dur));
|
||
}
|
||
|
||
// Sort and merge overlapping ranges
|
||
ranges.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||
let mut merged: Vec<(f64, f64)> = Vec::new();
|
||
for (s, e) in ranges {
|
||
if let Some(last) = merged.last_mut() {
|
||
if s <= last.1 {
|
||
last.1 = last.1.max(e);
|
||
} else {
|
||
merged.push((s, e));
|
||
}
|
||
} else {
|
||
merged.push((s, e));
|
||
}
|
||
}
|
||
|
||
// Check if any child clips are selected (for highlight)
|
||
let any_selected = child_clips.iter().any(|(_, ci)| selection.contains_clip_instance(&ci.id));
|
||
// Draw each merged span as a teal bar (brighter when selected)
|
||
let teal = if any_selected {
|
||
theme.bg_color(&["#timeline", ".group-bar", ".selected"], ui.ctx(), egui::Color32::from_rgb(30, 190, 190))
|
||
} else {
|
||
theme.bg_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(0, 150, 150))
|
||
};
|
||
let bright_teal = if any_selected {
|
||
theme.text_color(&["#timeline", ".group-bar", ".selected"], ui.ctx(), egui::Color32::from_rgb(150, 255, 255))
|
||
} else {
|
||
theme.text_color(&["#timeline", ".group-bar"], ui.ctx(), egui::Color32::from_rgb(100, 220, 220))
|
||
};
|
||
for (s, e) in &merged {
|
||
// `merged` ranges are in beats; convert to seconds for time_to_x.
|
||
let sx = self.time_to_x(document.tempo_map().transform(*s));
|
||
let ex = self.time_to_x(document.tempo_map().transform(*e)).max(sx + MIN_CLIP_WIDTH_PX);
|
||
if ex >= 0.0 && sx <= rect.width() {
|
||
let vsx = sx.max(0.0);
|
||
let vex = ex.min(rect.width());
|
||
let span_rect = egui::Rect::from_min_max(
|
||
egui::pos2(rect.min.x + vsx, y + 10.0),
|
||
egui::pos2(rect.min.x + vex, y + LAYER_HEIGHT - 10.0),
|
||
);
|
||
painter.rect_filled(span_rect, 3.0, teal);
|
||
painter.rect_stroke(
|
||
span_rect,
|
||
3.0,
|
||
egui::Stroke::new(1.0, bright_teal),
|
||
egui::StrokeKind::Middle,
|
||
);
|
||
}
|
||
}
|
||
|
||
// Render video thumbnails from the top video child layer
|
||
// and waveforms from audio child layers inside the collapsed group row
|
||
{
|
||
let span_y_min = y + 10.0;
|
||
let span_y_max = y + LAYER_HEIGHT - 10.0;
|
||
let span_height = span_y_max - span_y_min;
|
||
let thumb_y_max = span_y_min + span_height * (2.0 / 3.0);
|
||
let wave_y_min = thumb_y_max;
|
||
|
||
// Find the first (top) video child and draw thumbnails for its clips
|
||
if let Some(video_child) = g.children.iter().find(|c| matches!(c, AnyLayer::Video(_))) {
|
||
if let AnyLayer::Video(vl) = video_child {
|
||
for ci in &vl.clip_instances {
|
||
let clip_dur = document.get_clip_duration(&ci.clip_id)
|
||
.unwrap_or_else(|| ci.trim_end.unwrap_or(1.0) - ci.trim_start);
|
||
let mut ci_start = ci.effective_start();
|
||
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
||
ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
|
||
}
|
||
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
|
||
let ci_end = ci_start + ci_duration;
|
||
|
||
// ci_start/ci_end are in beats; convert to seconds for time_to_x.
|
||
let sx = self.time_to_x(document.tempo_map().transform(ci_start));
|
||
let ex = self.time_to_x(document.tempo_map().transform(ci_end));
|
||
if ex < 0.0 || sx > rect.width() { continue; }
|
||
|
||
let ci_rect = egui::Rect::from_min_max(
|
||
egui::pos2((rect.min.x + sx).max(rect.min.x), span_y_min),
|
||
egui::pos2((rect.min.x + ex).min(rect.max.x), thumb_y_max),
|
||
);
|
||
|
||
visible_video_clip_ids.insert(ci.clip_id);
|
||
|
||
// Collect for hover tooltip (use full span height as hover target)
|
||
let hover_rect = egui::Rect::from_min_max(
|
||
egui::pos2(ci_rect.min.x, span_y_min),
|
||
egui::pos2(ci_rect.max.x, span_y_max),
|
||
);
|
||
// 4th elem = clip's TRUE (unclamped) origin x, for correct
|
||
// hover content time when scrolled partly off the left.
|
||
video_clip_hovers.push((hover_rect, ci.clip_id, ci.trim_start, rect.min.x + sx));
|
||
|
||
let thumb_display_height = (thumb_y_max - span_y_min) - 4.0;
|
||
if thumb_display_height > 8.0 {
|
||
let video_mgr = video_manager.lock().unwrap();
|
||
// Tile from the clip's true origin (sx unclamped; ci_rect is
|
||
// the clamped visible rect) — scrolls correctly off-screen.
|
||
draw_video_thumbnail_strip(
|
||
&painter,
|
||
ui,
|
||
&video_mgr,
|
||
&mut self.video_thumbnail_textures,
|
||
ci.clip_id,
|
||
ci.trim_start,
|
||
rect.min.x + sx,
|
||
ex - sx,
|
||
ci_rect,
|
||
ci_rect.min.y + 2.0,
|
||
thumb_display_height,
|
||
self.pixels_per_second as f64,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw waveforms from audio child layers
|
||
let screen_size = ui.ctx().content_rect().size();
|
||
let waveform_tint = [
|
||
bright_teal.r() as f32 / 255.0,
|
||
bright_teal.g() as f32 / 255.0,
|
||
bright_teal.b() as f32 / 255.0,
|
||
bright_teal.a() as f32 / 255.0,
|
||
];
|
||
for child in &g.children {
|
||
if let AnyLayer::Audio(_) = child {
|
||
for ci in layer_clips(child, &audio_cache) {
|
||
let audio_clip = match document.get_audio_clip(&ci.clip_id) {
|
||
Some(c) => c,
|
||
None => continue,
|
||
};
|
||
let audio_pool_index = match audio_clip.audio_pool_index() {
|
||
Some(idx) => idx,
|
||
None => continue,
|
||
};
|
||
let (samples, sr, ch) = match raw_audio_cache.get(&audio_pool_index) {
|
||
Some(v) => v,
|
||
None => continue,
|
||
};
|
||
|
||
// Min/max overview pools store 4 f32 per texel at the
|
||
// floor rate sr/B; raw pools store interleaved samples.
|
||
let minmax_b = waveform_minmax_pools.get(&audio_pool_index).copied();
|
||
let is_minmax = minmax_b.is_some();
|
||
let frame_stride = if is_minmax { 4 } else { (*ch).max(1) as usize };
|
||
let total_frames = samples.len() / frame_stride;
|
||
let eff_sr: f32 = match minmax_b {
|
||
Some(b) => *sr as f32 / b.max(1) as f32,
|
||
None => *sr as f32,
|
||
};
|
||
let audio_file_duration = total_frames as f64 / eff_sr as f64;
|
||
|
||
let clip_dur = audio_clip.duration;
|
||
let mut ci_start = ci.effective_start();
|
||
if is_move_drag && selection.contains_clip_instance(&ci.id) {
|
||
ci_start = (ci_start + self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)).max(0.0);
|
||
}
|
||
let ci_duration = ci.total_duration(clip_dur, document.tempo_map());
|
||
|
||
// ci_start/ci_duration are in beats; convert to seconds for time_to_x.
|
||
let ci_screen_start = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start));
|
||
let ci_screen_end = rect.min.x + self.time_to_x(document.tempo_map().transform(ci_start + ci_duration));
|
||
|
||
let waveform_rect = egui::Rect::from_min_max(
|
||
egui::pos2(ci_screen_start.max(rect.min.x), wave_y_min),
|
||
egui::pos2(ci_screen_end.min(rect.max.x), span_y_max),
|
||
);
|
||
|
||
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
|
||
let pending_upload = if waveform_gpu_dirty.contains(&audio_pool_index) {
|
||
let chunk = crate::waveform_gpu::UPLOAD_CHUNK_FRAMES;
|
||
let progress = self.waveform_upload_progress.get(&audio_pool_index).copied().unwrap_or(0);
|
||
let next_end = (progress + chunk).min(total_frames);
|
||
let frame_limit = Some(next_end);
|
||
if next_end >= total_frames {
|
||
waveform_gpu_dirty.remove(&audio_pool_index);
|
||
self.waveform_upload_progress.remove(&audio_pool_index);
|
||
} else {
|
||
self.waveform_upload_progress.insert(audio_pool_index, next_end);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
Some(crate::waveform_gpu::PendingUpload {
|
||
samples: samples.clone(),
|
||
sample_rate: if is_minmax { eff_sr.round().max(1.0) as u32 } else { *sr },
|
||
channels: *ch,
|
||
frame_limit,
|
||
minmax: is_minmax,
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let instance_id = ci.id.as_u128() as u64;
|
||
let callback = crate::waveform_gpu::WaveformCallback {
|
||
pool_index: audio_pool_index,
|
||
segment_index: 0,
|
||
params: crate::waveform_gpu::WaveformParams {
|
||
clip_rect: [waveform_rect.min.x, waveform_rect.min.y, waveform_rect.max.x, waveform_rect.max.y],
|
||
viewport_start_time: self.viewport_start_time as f32,
|
||
pixels_per_second: self.pixels_per_second as f32,
|
||
audio_duration: audio_file_duration as f32,
|
||
sample_rate: eff_sr,
|
||
clip_start_time: ci_screen_start,
|
||
trim_start: ci.trim_start as f32,
|
||
tex_width: crate::waveform_gpu::tex_width() as f32,
|
||
total_frames: total_frames as f32,
|
||
segment_start_frame: 0.0,
|
||
display_mode: if waveform_stereo { 1.0 } else { 0.0 },
|
||
_pad1: [0.0, 0.0],
|
||
tint_color: waveform_tint,
|
||
screen_size: [screen_size.x, screen_size.y],
|
||
_pad: [0.0, 0.0],
|
||
},
|
||
target_format,
|
||
pending_upload,
|
||
instance_id,
|
||
};
|
||
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||
waveform_rect,
|
||
callback,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Separator line at bottom
|
||
painter.line_segment(
|
||
[
|
||
egui::pos2(layer_rect.min.x, layer_rect.max.y),
|
||
egui::pos2(layer_rect.max.x, layer_rect.max.y),
|
||
],
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||
);
|
||
|
||
// Collect automation lane renders for collapsed groups (e.g. master track)
|
||
if self.automation_expanded.contains(&row_layer_id) {
|
||
if let Some(lanes) = self.automation_cache.get(&row_layer_id) {
|
||
let group_color = theme.bg_color(&["#timeline", ".layer-type-group"], ui.ctx(), egui::Color32::from_rgb(0, 180, 180));
|
||
for (lane_idx, lane) in lanes.iter().enumerate() {
|
||
let lane_top = y + LAYER_HEIGHT + lane_idx as f32 * AUTOMATION_LANE_HEIGHT;
|
||
if lane_top + AUTOMATION_LANE_HEIGHT < rect.min.y || lane_top > rect.max.y {
|
||
continue;
|
||
}
|
||
let lane_rect = egui::Rect::from_min_size(
|
||
egui::pos2(rect.min.x, lane_top),
|
||
egui::vec2(rect.width(), AUTOMATION_LANE_HEIGHT),
|
||
);
|
||
let lane_accent = match lane.kind {
|
||
AutomationLaneKind::MasterTempo => egui::Color32::from_rgb(160, 80, 220),
|
||
AutomationLaneKind::Regular => group_color,
|
||
};
|
||
pending_lane_renders.push(AutomationLaneRender {
|
||
layer_id: row_layer_id,
|
||
node_id: lane.node_id,
|
||
lane_rect,
|
||
keyframes: lane.keyframes.clone(),
|
||
value_min: lane.value_min,
|
||
value_max: lane.value_max,
|
||
accent_color: lane_accent,
|
||
playback_time,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
continue; // Skip normal clip rendering for collapsed groups
|
||
}
|
||
|
||
// Get the AnyLayer for normal rendering (Normal or GroupChild rows)
|
||
let layer: &AnyLayer = match row {
|
||
TimelineRow::Normal(l) => l,
|
||
TimelineRow::GroupChild { child, .. } => child,
|
||
TimelineRow::CollapsedGroup { .. } => unreachable!(), // handled above
|
||
};
|
||
|
||
// Draw clip instances for this layer
|
||
let clip_instances = layer_clips(layer, &audio_cache);
|
||
|
||
// For moves, precompute the clamped offset so all selected clips move uniformly
|
||
let group_move_offset = if self.clip_drag_state == Some(ClipDragType::Move) {
|
||
let group: Vec<(uuid::Uuid, f64, f64)> = clip_instances.iter()
|
||
.filter(|ci| selection.contains_clip_instance(&ci.id))
|
||
.filter_map(|ci| {
|
||
let dur = document.get_clip_duration(&ci.clip_id)?;
|
||
Some((ci.id, ci.effective_start(), ci.total_duration(dur, document.tempo_map())))
|
||
})
|
||
.collect();
|
||
if !group.is_empty() {
|
||
Some(document.clamp_group_move_offset(&layer.id(), &group, self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate)))
|
||
} else {
|
||
None
|
||
}
|
||
} else {
|
||
None
|
||
};
|
||
|
||
// Compute stacking using preview positions (with drag offsets) for vector layers
|
||
let clip_stacking = if matches!(layer, AnyLayer::Vector(_)) && clip_instances.len() > 1 {
|
||
let preview_ranges: Vec<(f64, f64)> = clip_instances.iter().map(|ci| {
|
||
let clip_dur = effective_clip_duration(document, layer, ci).unwrap_or(0.0);
|
||
let mut start = ci.effective_start();
|
||
let mut duration = ci.total_duration(clip_dur, document.tempo_map());
|
||
|
||
let is_selected = selection.contains_clip_instance(&ci.id);
|
||
let is_linked = if self.clip_drag_state.is_some() {
|
||
instance_to_group.get(&ci.id).map_or(false, |group| {
|
||
group.members.iter().any(|(_, mid)| *mid != ci.id && selection.contains_clip_instance(mid))
|
||
})
|
||
} else {
|
||
false
|
||
};
|
||
|
||
if let Some(drag_type) = self.clip_drag_state {
|
||
if is_selected || is_linked {
|
||
match drag_type {
|
||
ClipDragType::Move => {
|
||
if let Some(offset) = group_move_offset {
|
||
start = (ci.effective_start() + offset).max(0.0);
|
||
}
|
||
}
|
||
ClipDragType::TrimLeft => {
|
||
let new_trim = self.snap_to_grid(ci.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(0.0).min(clip_dur);
|
||
let offset = new_trim - ci.trim_start;
|
||
start = (ci.timeline_start + offset).max(0.0);
|
||
duration = (clip_dur - new_trim).max(0.0);
|
||
if let Some(trim_end) = ci.trim_end {
|
||
duration = (trim_end - new_trim).max(0.0);
|
||
}
|
||
}
|
||
ClipDragType::TrimRight => {
|
||
let old_trim_end = ci.trim_end.unwrap_or(clip_dur);
|
||
let new_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate).max(ci.trim_start).min(clip_dur);
|
||
duration = (new_trim_end - ci.trim_start).max(0.0);
|
||
}
|
||
ClipDragType::LoopExtendRight => {
|
||
let trim_end = ci.trim_end.unwrap_or(clip_dur);
|
||
let content_window = (trim_end - ci.trim_start).max(0.0);
|
||
let current_right = ci.timeline_duration.unwrap_or(content_window);
|
||
let right_edge = ci.timeline_start + current_right + self.drag_offset;
|
||
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
|
||
let new_right = (snapped_edge - ci.timeline_start).max(content_window);
|
||
let loop_before = ci.loop_before.unwrap_or(0.0);
|
||
duration = loop_before + new_right;
|
||
}
|
||
ClipDragType::LoopExtendLeft => {
|
||
let trim_end = ci.trim_end.unwrap_or(clip_dur);
|
||
let content_window = (trim_end - ci.trim_start).max(0.001);
|
||
let current_loop_before = ci.loop_before.unwrap_or(0.0);
|
||
let desired = (current_loop_before - self.drag_offset).max(0.0);
|
||
let snapped = (desired / content_window).round() * content_window;
|
||
start = ci.timeline_start - snapped;
|
||
duration = snapped + ci.effective_duration(clip_dur, document.tempo_map());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
(start, start + duration)
|
||
}).collect();
|
||
compute_clip_stacking_from_ranges(&preview_ranges)
|
||
} else {
|
||
compute_clip_stacking(document, layer, clip_instances)
|
||
};
|
||
for (clip_instance_index, clip_instance) in clip_instances.iter().enumerate() {
|
||
// Get the clip to determine duration
|
||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||
|
||
if let Some(clip_duration) = clip_duration {
|
||
// Calculate effective duration accounting for trimming.
|
||
// In Measures mode, uses beats fields so width tracks BPM during live drag.
|
||
let mut instance_duration = self.instance_display_duration(clip_instance, clip_duration, document.tempo_map());
|
||
|
||
// Instance positioned on the layer's timeline using timeline_start.
|
||
// In Measures mode, uses timeline_start_beats so clips stay at their beat
|
||
// position during live BPM drag preview.
|
||
let _layer_data = layer.layer();
|
||
let mut instance_start = self.instance_display_start(clip_instance, document.tempo_map());
|
||
|
||
// Apply drag offset preview for selected clips with snapping
|
||
let is_selected = selection.contains_clip_instance(&clip_instance.id);
|
||
|
||
// Check if this clip is linked to a selected clip being dragged
|
||
let is_linked_to_dragged = if self.clip_drag_state.is_some() {
|
||
if let Some(group) = instance_to_group.get(&clip_instance.id) {
|
||
// Check if any OTHER member of this group is selected
|
||
group.members.iter().any(|(_, member_id)| {
|
||
*member_id != clip_instance.id && selection.contains_clip_instance(member_id)
|
||
})
|
||
} else {
|
||
false
|
||
}
|
||
} else {
|
||
false
|
||
};
|
||
|
||
// Content origin: where the first "real" content iteration starts
|
||
// Loop iterations tile outward from this point
|
||
let mut content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0);
|
||
|
||
// Track preview trim values for note/waveform rendering.
|
||
// In Measures mode, derive from beats so they track BPM during live drag.
|
||
let (base_trim_start, base_clip_duration) = self.content_display_range(clip_instance, clip_duration, document.bpm());
|
||
let mut preview_trim_start = base_trim_start;
|
||
let mut preview_clip_duration = base_clip_duration;
|
||
|
||
if let Some(drag_type) = self.clip_drag_state {
|
||
if is_selected || is_linked_to_dragged {
|
||
match drag_type {
|
||
ClipDragType::Move => {
|
||
if let Some(offset) = group_move_offset {
|
||
instance_start = (clip_instance.effective_start() + offset).max(0.0);
|
||
content_origin = instance_start + clip_instance.loop_before.unwrap_or(0.0);
|
||
}
|
||
}
|
||
ClipDragType::TrimLeft => {
|
||
// Trim left: calculate new trim_start with snap to adjacent clips
|
||
let desired_trim_start = self.snap_to_grid(clip_instance.trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
|
||
.max(0.0)
|
||
.min(clip_duration);
|
||
|
||
let new_trim_start = if desired_trim_start < clip_instance.trim_start {
|
||
// Extending left - check for adjacent clips
|
||
let max_extend = document.find_max_trim_extend_left(
|
||
&layer.id(),
|
||
&clip_instance.id,
|
||
clip_instance.effective_start(),
|
||
);
|
||
|
||
let desired_extend = clip_instance.trim_start - desired_trim_start;
|
||
let actual_extend = desired_extend.min(max_extend);
|
||
clip_instance.trim_start - actual_extend
|
||
} else {
|
||
// Shrinking - no snap needed
|
||
desired_trim_start
|
||
};
|
||
|
||
let actual_offset = new_trim_start - clip_instance.trim_start;
|
||
|
||
// Move start and reduce duration by actual clamped offset
|
||
instance_start = (clip_instance.timeline_start + actual_offset)
|
||
.max(0.0);
|
||
|
||
instance_duration = (clip_duration - new_trim_start).max(0.0);
|
||
|
||
// Adjust for existing trim_end
|
||
if let Some(trim_end) = clip_instance.trim_end {
|
||
instance_duration = (trim_end - new_trim_start).max(0.0);
|
||
}
|
||
|
||
// Update preview trim for waveform rendering
|
||
preview_trim_start = new_trim_start;
|
||
preview_clip_duration = instance_duration;
|
||
}
|
||
ClipDragType::TrimRight => {
|
||
// Trim right: extend or reduce duration with snap to adjacent clips
|
||
let old_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||
let desired_trim_end = self.snap_to_grid(old_trim_end + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate)
|
||
.max(clip_instance.trim_start)
|
||
.min(clip_duration);
|
||
|
||
let new_trim_end = if desired_trim_end > old_trim_end {
|
||
// Extending right - check for adjacent clips
|
||
let current_duration = old_trim_end - clip_instance.trim_start;
|
||
let max_extend = document.find_max_trim_extend_right(
|
||
&layer.id(),
|
||
&clip_instance.id,
|
||
clip_instance.timeline_start,
|
||
current_duration,
|
||
);
|
||
|
||
let desired_extend = desired_trim_end - old_trim_end;
|
||
let actual_extend = desired_extend.min(max_extend);
|
||
old_trim_end + actual_extend
|
||
} else {
|
||
// Shrinking - no snap needed
|
||
desired_trim_end
|
||
};
|
||
|
||
instance_duration = (new_trim_end - clip_instance.trim_start).max(0.0);
|
||
|
||
// Update preview clip duration for waveform rendering
|
||
// (the waveform system uses clip_duration to determine visible range)
|
||
preview_clip_duration = new_trim_end - preview_trim_start;
|
||
}
|
||
ClipDragType::LoopExtendRight => {
|
||
// Loop extend right: extend clip beyond content window
|
||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
|
||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset;
|
||
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
|
||
let desired_right = (snapped_edge - clip_instance.timeline_start).max(content_window);
|
||
|
||
let new_right = if desired_right > current_right {
|
||
let max_extend = document.find_max_trim_extend_right(
|
||
&layer.id(),
|
||
&clip_instance.id,
|
||
clip_instance.timeline_start,
|
||
current_right,
|
||
);
|
||
let extend_amount = (desired_right - current_right).min(max_extend);
|
||
current_right + extend_amount
|
||
} else {
|
||
desired_right
|
||
};
|
||
|
||
// Total duration = loop_before + right duration
|
||
let loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
||
instance_duration = loop_before + new_right;
|
||
}
|
||
ClipDragType::LoopExtendLeft => {
|
||
// Loop extend left: extend loop_before (pre-loop region)
|
||
// Snap to multiples of content_window so iterations align with backend
|
||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
|
||
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
||
// Invert: dragging left (negative offset) = extend
|
||
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
|
||
// Snap to whole iterations
|
||
let desired_iters = (desired_loop_before / content_window).round();
|
||
let snapped_loop_before = desired_iters * content_window;
|
||
|
||
let new_loop_before = if snapped_loop_before > current_loop_before {
|
||
// Extending left - check for adjacent clips
|
||
let max_extend = document.find_max_loop_extend_left(
|
||
&layer.id(),
|
||
&clip_instance.id,
|
||
clip_instance.effective_start(),
|
||
);
|
||
let extend_amount = (snapped_loop_before - current_loop_before).min(max_extend);
|
||
// Re-snap after clamping
|
||
let clamped = current_loop_before + extend_amount;
|
||
(clamped / content_window).floor() * content_window
|
||
} else {
|
||
snapped_loop_before
|
||
};
|
||
|
||
// Recompute instance_start and instance_duration
|
||
let right_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
|
||
instance_start = clip_instance.timeline_start - new_loop_before;
|
||
instance_duration = new_loop_before + right_duration;
|
||
content_origin = clip_instance.timeline_start;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
let instance_end = instance_start + instance_duration;
|
||
|
||
let start_x = self.time_to_x(instance_start);
|
||
let end_x = self.time_to_x(instance_end).max(start_x + MIN_CLIP_WIDTH_PX);
|
||
|
||
// Only draw if any part is visible in viewport
|
||
if end_x >= 0.0 && start_x <= rect.width() {
|
||
let visible_start_x = start_x.max(0.0);
|
||
let visible_end_x = end_x.min(rect.width());
|
||
|
||
// Choose color based on layer type
|
||
let (clip_color, bright_color) = {
|
||
let (_, base_color) = layer_type_info(layer);
|
||
// Derive bright version by lightening each channel
|
||
let bright = egui::Color32::from_rgb(
|
||
(base_color.r() as u16 + 80).min(255) as u8,
|
||
(base_color.g() as u16 + 60).min(255) as u8,
|
||
(base_color.b() as u16 + 70).min(255) as u8,
|
||
);
|
||
(base_color, bright)
|
||
};
|
||
|
||
let (row, total_rows) = clip_stacking[clip_instance_index];
|
||
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
|
||
|
||
let clip_rect = egui::Rect::from_min_max(
|
||
egui::pos2(rect.min.x + visible_start_x, y + cy_min),
|
||
egui::pos2(rect.min.x + visible_end_x, y + cy_max),
|
||
);
|
||
|
||
// Draw the clip instance background(s)
|
||
// For looping clips, draw each iteration as a separate rounded rect
|
||
// Use preview_clip_duration so trim drag previews don't show false loop iterations
|
||
let content_window_for_bg = preview_clip_duration.max(0.0);
|
||
let is_looping_bg = instance_duration > content_window_for_bg + 0.001 && content_window_for_bg > 0.0;
|
||
|
||
if is_looping_bg {
|
||
// Compute iterations aligned to content_origin
|
||
let loop_before_val = content_origin - instance_start;
|
||
let pre_iters = if loop_before_val > 0.001 {
|
||
(loop_before_val / content_window_for_bg).ceil() as usize
|
||
} else {
|
||
0
|
||
};
|
||
let right_duration = instance_duration - loop_before_val;
|
||
let post_iters = if right_duration > 0.001 {
|
||
(right_duration / content_window_for_bg).ceil() as usize
|
||
} else {
|
||
1
|
||
};
|
||
let total_iters = pre_iters + post_iters;
|
||
|
||
let faded_color = egui::Color32::from_rgba_unmultiplied(
|
||
clip_color.r(), clip_color.g(), clip_color.b(),
|
||
(clip_color.a() as f32 * 0.55) as u8,
|
||
);
|
||
for i in 0..total_iters {
|
||
let signed_i = i as i64 - pre_iters as i64;
|
||
let iter_time_start_raw = content_origin + signed_i as f64 * content_window_for_bg;
|
||
let iter_time_end_raw = iter_time_start_raw + content_window_for_bg;
|
||
let iter_time_start = iter_time_start_raw.max(instance_start);
|
||
let iter_time_end = iter_time_end_raw.min(instance_start + instance_duration);
|
||
if iter_time_end <= iter_time_start { continue; }
|
||
|
||
let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x);
|
||
let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x);
|
||
if ix1 > ix0 {
|
||
let iter_rect = egui::Rect::from_min_max(
|
||
egui::pos2(ix0, clip_rect.min.y),
|
||
egui::pos2(ix1, clip_rect.max.y),
|
||
);
|
||
let color = if signed_i == 0 { clip_color } else { faded_color };
|
||
painter.rect_filled(iter_rect, 3.0, color);
|
||
}
|
||
}
|
||
} else {
|
||
painter.rect_filled(
|
||
clip_rect,
|
||
3.0,
|
||
clip_color,
|
||
);
|
||
}
|
||
|
||
// AUDIO VISUALIZATION: Draw piano roll or waveform overlay
|
||
if let lightningbeam_core::layer::AnyLayer::Audio(_) = layer {
|
||
if let Some(clip) = document.get_audio_clip(&clip_instance.clip_id) {
|
||
match &clip.clip_type {
|
||
// MIDI: Draw piano roll (with loop iterations)
|
||
lightningbeam_core::clip::AudioClipType::Midi { midi_clip_id } => {
|
||
if let Some(events) = midi_event_cache.get(midi_clip_id) {
|
||
// Calculate content window for loop detection
|
||
// preview_clip_duration accounts for TrimLeft/TrimRight drag previews
|
||
let content_window = preview_clip_duration.max(0.0);
|
||
let is_looping = instance_duration > content_window + 0.001;
|
||
|
||
if is_looping && content_window > 0.0 {
|
||
// Compute iterations aligned to content_origin
|
||
let lb_val = content_origin - instance_start;
|
||
let pre = if lb_val > 0.001 { (lb_val / content_window).ceil() as usize } else { 0 };
|
||
let right_dur = instance_duration - lb_val;
|
||
let post = if right_dur > 0.001 { (right_dur / content_window).ceil() as usize } else { 1 };
|
||
|
||
for i in 0..(pre + post) {
|
||
let si = i as i64 - pre as i64;
|
||
let iter_start_raw = content_origin + si as f64 * content_window;
|
||
let iter_end_raw = iter_start_raw + content_window;
|
||
let iter_start = iter_start_raw.max(instance_start);
|
||
let iter_end = iter_end_raw.min(instance_start + instance_duration);
|
||
let iter_duration = iter_end - iter_start;
|
||
if iter_duration <= 0.0 { continue; }
|
||
|
||
Self::render_midi_piano_roll(
|
||
&painter,
|
||
clip_rect,
|
||
rect.min.x,
|
||
events,
|
||
preview_trim_start,
|
||
iter_duration,
|
||
iter_start,
|
||
self.viewport_start_time,
|
||
self.pixels_per_second,
|
||
theme,
|
||
ui.ctx(),
|
||
si != 0, // fade non-content iterations
|
||
document.tempo_map(),
|
||
);
|
||
}
|
||
} else {
|
||
Self::render_midi_piano_roll(
|
||
&painter,
|
||
clip_rect,
|
||
rect.min.x,
|
||
events,
|
||
preview_trim_start,
|
||
instance_duration,
|
||
instance_start,
|
||
self.viewport_start_time,
|
||
self.pixels_per_second,
|
||
theme,
|
||
ui.ctx(),
|
||
false,
|
||
document.tempo_map(),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
// Sampled Audio: Draw waveform via GPU
|
||
lightningbeam_core::clip::AudioClipType::Sampled { audio_pool_index } => {
|
||
if let Some((samples, sr, ch)) = raw_audio_cache.get(audio_pool_index) {
|
||
// Min/max overview pools: 4 f32/texel at rate sr/B.
|
||
let minmax_b = waveform_minmax_pools.get(audio_pool_index).copied();
|
||
let is_minmax = minmax_b.is_some();
|
||
let frame_stride = if is_minmax { 4 } else { (*ch).max(1) as usize };
|
||
let total_frames = samples.len() / frame_stride;
|
||
let eff_sr: f32 = match minmax_b {
|
||
Some(b) => *sr as f32 / b.max(1) as f32,
|
||
None => *sr as f32,
|
||
};
|
||
let audio_file_duration = total_frames as f64 / eff_sr as f64;
|
||
let screen_size = ui.ctx().content_rect().size();
|
||
|
||
let pending_upload = if waveform_gpu_dirty.contains(audio_pool_index) {
|
||
// Chunked upload: track progress across frames
|
||
let chunk = crate::waveform_gpu::UPLOAD_CHUNK_FRAMES;
|
||
let progress = self.waveform_upload_progress.get(audio_pool_index).copied().unwrap_or(0);
|
||
let next_end = (progress + chunk).min(total_frames);
|
||
let frame_limit = Some(next_end);
|
||
|
||
if next_end >= total_frames {
|
||
// Final chunk — done
|
||
waveform_gpu_dirty.remove(audio_pool_index);
|
||
self.waveform_upload_progress.remove(audio_pool_index);
|
||
} else {
|
||
// More chunks needed
|
||
self.waveform_upload_progress.insert(*audio_pool_index, next_end);
|
||
ui.ctx().request_repaint();
|
||
}
|
||
|
||
Some(crate::waveform_gpu::PendingUpload {
|
||
samples: samples.clone(),
|
||
sample_rate: if is_minmax { eff_sr.round().max(1.0) as u32 } else { *sr },
|
||
channels: *ch,
|
||
frame_limit,
|
||
minmax: is_minmax,
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let tint = [
|
||
bright_color.r() as f32 / 255.0,
|
||
bright_color.g() as f32 / 255.0,
|
||
bright_color.b() as f32 / 255.0,
|
||
bright_color.a() as f32 / 255.0,
|
||
];
|
||
|
||
// Calculate content window for loop detection
|
||
// Use trimmed content window (preview_trim_start accounts for TrimLeft drag)
|
||
let preview_trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||
let content_window = (preview_trim_end - preview_trim_start).max(0.0);
|
||
let is_looping = instance_duration > content_window + 0.001;
|
||
|
||
// Compute iterations aligned to content_origin
|
||
let lb_val = content_origin - instance_start;
|
||
let pre_w = if is_looping && lb_val > 0.001 { (lb_val / content_window).ceil() as usize } else { 0 };
|
||
let right_dur_w = instance_duration - lb_val;
|
||
let post_w = if is_looping && content_window > 0.0 {
|
||
(right_dur_w / content_window).ceil() as usize
|
||
} else {
|
||
1
|
||
};
|
||
let total_w = pre_w + post_w;
|
||
|
||
for wi in 0..total_w {
|
||
let si_w = wi as i64 - pre_w as i64;
|
||
let (iter_start, iter_duration) = if is_looping {
|
||
let raw_start = content_origin + si_w as f64 * content_window;
|
||
let raw_end = raw_start + content_window;
|
||
let s = raw_start.max(instance_start);
|
||
let e = raw_end.min(instance_start + instance_duration);
|
||
(s, (e - s).max(0.0))
|
||
} else {
|
||
(instance_start, instance_duration)
|
||
};
|
||
|
||
if iter_duration <= 0.0 { continue; }
|
||
|
||
let iter_screen_start = rect.min.x + ((iter_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32;
|
||
let iter_screen_end = iter_screen_start + (iter_duration * self.pixels_per_second as f64) as f32;
|
||
let waveform_rect = egui::Rect::from_min_max(
|
||
egui::pos2(iter_screen_start.max(clip_rect.min.x), clip_rect.min.y),
|
||
egui::pos2(iter_screen_end.min(clip_rect.max.x), clip_rect.max.y),
|
||
);
|
||
|
||
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
|
||
let instance_id = clip_instance.id.as_u128() as u64 + wi as u64;
|
||
let is_loop_iter = si_w != 0;
|
||
let callback = crate::waveform_gpu::WaveformCallback {
|
||
pool_index: *audio_pool_index,
|
||
segment_index: 0,
|
||
params: crate::waveform_gpu::WaveformParams {
|
||
clip_rect: [waveform_rect.min.x, waveform_rect.min.y, waveform_rect.max.x, waveform_rect.max.y],
|
||
viewport_start_time: self.viewport_start_time as f32,
|
||
pixels_per_second: self.pixels_per_second as f32,
|
||
audio_duration: audio_file_duration as f32,
|
||
sample_rate: eff_sr,
|
||
clip_start_time: iter_screen_start,
|
||
trim_start: preview_trim_start as f32,
|
||
tex_width: crate::waveform_gpu::tex_width() as f32,
|
||
total_frames: total_frames as f32,
|
||
segment_start_frame: 0.0,
|
||
display_mode: if waveform_stereo { 1.0 } else { 0.0 },
|
||
_pad1: [0.0, 0.0],
|
||
tint_color: if is_loop_iter {
|
||
[tint[0], tint[1], tint[2], tint[3] * 0.5]
|
||
} else {
|
||
tint
|
||
},
|
||
screen_size: [screen_size.x, screen_size.y],
|
||
_pad: [0.0, 0.0],
|
||
},
|
||
target_format,
|
||
pending_upload: if wi == 0 { pending_upload.clone() } else { None },
|
||
instance_id,
|
||
};
|
||
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||
waveform_rect,
|
||
callback,
|
||
));
|
||
}
|
||
|
||
}
|
||
}
|
||
}
|
||
// Recording in progress: show live waveform
|
||
lightningbeam_core::clip::AudioClipType::Recording => {
|
||
let rec_pool_idx = usize::MAX;
|
||
if let Some((samples, sr, ch)) = raw_audio_cache.get(&rec_pool_idx) {
|
||
let total_frames = samples.len() / (*ch).max(1) as usize;
|
||
if total_frames > 0 {
|
||
let audio_file_duration = total_frames as f64 / *sr as f64;
|
||
let screen_size = ui.ctx().content_rect().size();
|
||
|
||
let pending_upload = if waveform_gpu_dirty.contains(&rec_pool_idx) {
|
||
waveform_gpu_dirty.remove(&rec_pool_idx);
|
||
Some(crate::waveform_gpu::PendingUpload {
|
||
samples: samples.clone(),
|
||
sample_rate: *sr,
|
||
channels: *ch,
|
||
frame_limit: None, // recording uses incremental path
|
||
minmax: false,
|
||
})
|
||
} else {
|
||
None
|
||
};
|
||
|
||
let tint = [
|
||
bright_color.r() as f32 / 255.0,
|
||
bright_color.g() as f32 / 255.0,
|
||
bright_color.b() as f32 / 255.0,
|
||
bright_color.a() as f32 / 255.0,
|
||
];
|
||
|
||
let clip_screen_start = rect.min.x + ((instance_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32;
|
||
let clip_screen_end = clip_screen_start + (preview_clip_duration * self.pixels_per_second as f64) as f32;
|
||
let waveform_rect = egui::Rect::from_min_max(
|
||
egui::pos2(clip_screen_start.max(clip_rect.min.x), clip_rect.min.y),
|
||
egui::pos2(clip_screen_end.min(clip_rect.max.x), clip_rect.max.y),
|
||
);
|
||
|
||
if waveform_rect.width() > 0.0 && waveform_rect.height() > 0.0 {
|
||
let instance_id = clip_instance.id.as_u128() as u64;
|
||
let callback = crate::waveform_gpu::WaveformCallback {
|
||
pool_index: rec_pool_idx,
|
||
segment_index: 0,
|
||
params: crate::waveform_gpu::WaveformParams {
|
||
clip_rect: [waveform_rect.min.x, waveform_rect.min.y, waveform_rect.max.x, waveform_rect.max.y],
|
||
viewport_start_time: self.viewport_start_time as f32,
|
||
pixels_per_second: self.pixels_per_second as f32,
|
||
audio_duration: audio_file_duration as f32,
|
||
sample_rate: *sr as f32,
|
||
clip_start_time: clip_screen_start,
|
||
trim_start: preview_trim_start as f32,
|
||
tex_width: crate::waveform_gpu::tex_width() as f32,
|
||
total_frames: total_frames as f32,
|
||
segment_start_frame: 0.0,
|
||
display_mode: if waveform_stereo { 1.0 } else { 0.0 },
|
||
_pad1: [0.0, 0.0],
|
||
tint_color: tint,
|
||
screen_size: [screen_size.x, screen_size.y],
|
||
_pad: [0.0, 0.0],
|
||
},
|
||
target_format,
|
||
pending_upload,
|
||
instance_id,
|
||
};
|
||
|
||
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
|
||
waveform_rect,
|
||
callback,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// VIDEO THUMBNAIL STRIP: Draw sequence of thumbnails inside clip rect
|
||
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
|
||
visible_video_clip_ids.insert(clip_instance.clip_id);
|
||
let thumb_display_height = clip_rect.height() - 4.0;
|
||
if thumb_display_height > 8.0 {
|
||
let video_mgr = video_manager.lock().unwrap();
|
||
// Tile from the clip's true origin (start_x is unclamped;
|
||
// clip_rect is the clamped visible rect) so the strip scrolls
|
||
// correctly and shows the right content when partly off-screen.
|
||
draw_video_thumbnail_strip(
|
||
&painter,
|
||
ui,
|
||
&video_mgr,
|
||
&mut self.video_thumbnail_textures,
|
||
clip_instance.clip_id,
|
||
clip_instance.trim_start,
|
||
rect.min.x + start_x,
|
||
end_x - start_x,
|
||
clip_rect,
|
||
clip_rect.min.y + 2.0,
|
||
thumb_display_height,
|
||
self.pixels_per_second as f64,
|
||
);
|
||
}
|
||
}
|
||
|
||
// VIDEO PREVIEW: Collect clip rect for hover detection. Store the
|
||
// clip's TRUE (unclamped) origin x so the hover content time is
|
||
// correct even when the clip is scrolled partly off the left.
|
||
if let lightningbeam_core::layer::AnyLayer::Video(_) = layer {
|
||
video_clip_hovers.push((clip_rect, clip_instance.clip_id, clip_instance.trim_start, rect.min.x + start_x));
|
||
}
|
||
|
||
// Draw border per segment (per loop iteration for looping clips)
|
||
{
|
||
let is_selected = selection.contains_clip_instance(&clip_instance.id);
|
||
let border_stroke = if is_selected {
|
||
egui::Stroke::new(3.0, bright_color)
|
||
} else {
|
||
let dark_border = egui::Color32::from_rgb(
|
||
clip_color.r() / 2,
|
||
clip_color.g() / 2,
|
||
clip_color.b() / 2,
|
||
);
|
||
egui::Stroke::new(1.0, dark_border)
|
||
};
|
||
|
||
if is_looping_bg {
|
||
// Aligned to content_origin (same as bg rendering)
|
||
let lb_border = content_origin - instance_start;
|
||
let pre_b = if lb_border > 0.001 { (lb_border / content_window_for_bg).ceil() as usize } else { 0 };
|
||
let right_b = instance_duration - lb_border;
|
||
let post_b = if right_b > 0.001 { (right_b / content_window_for_bg).ceil() as usize } else { 1 };
|
||
for i in 0..(pre_b + post_b) {
|
||
let si_b = i as i64 - pre_b as i64;
|
||
let iter_time_start_raw = content_origin + si_b as f64 * content_window_for_bg;
|
||
let iter_time_end_raw = iter_time_start_raw + content_window_for_bg;
|
||
let iter_time_start = iter_time_start_raw.max(instance_start);
|
||
let iter_time_end = iter_time_end_raw.min(instance_start + instance_duration);
|
||
if iter_time_end <= iter_time_start { continue; }
|
||
let ix0 = (rect.min.x + ((iter_time_start - self.viewport_start_time) * self.pixels_per_second as f64) as f32).max(clip_rect.min.x);
|
||
let ix1 = (rect.min.x + ((iter_time_end - self.viewport_start_time) * self.pixels_per_second as f64) as f32).min(clip_rect.max.x);
|
||
if ix1 > ix0 {
|
||
let iter_rect = egui::Rect::from_min_max(
|
||
egui::pos2(ix0, clip_rect.min.y),
|
||
egui::pos2(ix1, clip_rect.max.y),
|
||
);
|
||
painter.rect_stroke(iter_rect, 3.0, border_stroke, egui::StrokeKind::Middle);
|
||
}
|
||
}
|
||
} else {
|
||
painter.rect_stroke(clip_rect, 3.0, border_stroke, egui::StrokeKind::Middle);
|
||
}
|
||
}
|
||
|
||
// Draw clip name if there's space
|
||
if let Some(name) = &clip_instance.name {
|
||
if clip_rect.width() > 50.0 {
|
||
painter.text(
|
||
clip_rect.min + egui::vec2(5.0, 5.0),
|
||
egui::Align2::LEFT_TOP,
|
||
name,
|
||
egui::FontId::proportional(11.0),
|
||
egui::Color32::WHITE,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw shape keyframe markers for vector layers
|
||
if let lightningbeam_core::layer::AnyLayer::Vector(vl) = layer {
|
||
for kf in &vl.keyframes {
|
||
let x = self.time_to_x(kf.time);
|
||
if x >= 0.0 && x <= rect.width() {
|
||
let cx = rect.min.x + x;
|
||
let cy = y + LAYER_HEIGHT - 8.0;
|
||
let size = 5.0;
|
||
// Draw diamond shape
|
||
let diamond = [
|
||
egui::pos2(cx, cy - size),
|
||
egui::pos2(cx + size, cy),
|
||
egui::pos2(cx, cy + size),
|
||
egui::pos2(cx - size, cy),
|
||
];
|
||
let color = theme.bg_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(255, 220, 100));
|
||
painter.add(egui::Shape::convex_polygon(
|
||
diamond.to_vec(),
|
||
color,
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))),
|
||
));
|
||
// Record a (slightly enlarged) clickable area for click-to-seek.
|
||
self.keyframe_diamond_hits.push((
|
||
egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(13.0, 13.0)),
|
||
kf.time,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Draw keyframe markers for raster layers (same diamond as vector).
|
||
if let lightningbeam_core::layer::AnyLayer::Raster(rl) = layer {
|
||
for kf in &rl.keyframes {
|
||
let x = self.time_to_x(kf.time);
|
||
if x >= 0.0 && x <= rect.width() {
|
||
let cx = rect.min.x + x;
|
||
let cy = y + LAYER_HEIGHT - 8.0;
|
||
let size = 5.0;
|
||
let diamond = [
|
||
egui::pos2(cx, cy - size),
|
||
egui::pos2(cx + size, cy),
|
||
egui::pos2(cx, cy + size),
|
||
egui::pos2(cx - size, cy),
|
||
];
|
||
let color = theme.bg_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(255, 220, 100));
|
||
painter.add(egui::Shape::convex_polygon(
|
||
diamond.to_vec(),
|
||
color,
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".keyframe-diamond"], ui.ctx(), egui::Color32::from_rgb(180, 150, 50))),
|
||
));
|
||
// Record a (slightly enlarged) clickable area for click-to-seek.
|
||
self.keyframe_diamond_hits.push((
|
||
egui::Rect::from_center_size(egui::pos2(cx, cy), egui::vec2(13.0, 13.0)),
|
||
kf.time,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Separator line at bottom
|
||
painter.line_segment(
|
||
[
|
||
egui::pos2(layer_rect.min.x, layer_rect.max.y),
|
||
egui::pos2(layer_rect.max.x, layer_rect.max.y),
|
||
],
|
||
egui::Stroke::new(1.0, theme.border_color(&["#timeline", ".separator"], ui.ctx(), egui::Color32::from_gray(20))),
|
||
);
|
||
|
||
// Collect automation lane render data — actual render_curve_lane calls happen after
|
||
// handle_input so our widgets register last and win egui's interaction priority.
|
||
if self.automation_expanded.contains(&row_layer_id) {
|
||
if let Some(lanes) = self.automation_cache.get(&row_layer_id) {
|
||
let (_, tc) = layer_type_info(layer);
|
||
for (lane_idx, lane) in lanes.iter().enumerate() {
|
||
let lane_top = y + LAYER_HEIGHT + lane_idx as f32 * AUTOMATION_LANE_HEIGHT;
|
||
if lane_top + AUTOMATION_LANE_HEIGHT < rect.min.y || lane_top > rect.max.y {
|
||
continue;
|
||
}
|
||
let lane_rect = egui::Rect::from_min_size(
|
||
egui::pos2(rect.min.x, lane_top),
|
||
egui::vec2(rect.width(), AUTOMATION_LANE_HEIGHT),
|
||
);
|
||
let lane_accent = match lane.kind {
|
||
AutomationLaneKind::MasterTempo => egui::Color32::from_rgb(160, 80, 220),
|
||
AutomationLaneKind::Regular => tc,
|
||
};
|
||
pending_lane_renders.push(AutomationLaneRender {
|
||
layer_id: row_layer_id,
|
||
node_id: lane.node_id,
|
||
lane_rect,
|
||
keyframes: lane.keyframes.clone(),
|
||
value_min: lane.value_min,
|
||
value_max: lane.value_max,
|
||
accent_color: lane_accent,
|
||
playback_time,
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Clean up stale video thumbnail textures for clips no longer visible
|
||
self.video_thumbnail_textures.retain(|&(clip_id, _), _| visible_video_clip_ids.contains(&clip_id));
|
||
|
||
// Return video clip hover data and pending lane renders for processing after input handling
|
||
(video_clip_hovers, pending_lane_renders)
|
||
}
|
||
|
||
/// Handle mouse input for scrubbing, panning, zooming, layer selection, and clip instance selection
|
||
fn handle_input(
|
||
&mut self,
|
||
ui: &mut egui::Ui,
|
||
_full_timeline_rect: egui::Rect,
|
||
ruler_rect: egui::Rect,
|
||
content_rect: egui::Rect,
|
||
header_rect: egui::Rect,
|
||
layer_count: usize,
|
||
document: &lightningbeam_core::document::Document,
|
||
active_layer_id: &mut Option<uuid::Uuid>,
|
||
selection: &mut lightningbeam_core::selection::Selection,
|
||
focus: &mut lightningbeam_core::selection::FocusSelection,
|
||
pending_actions: &mut Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||
playback_time: &mut f64,
|
||
_is_playing: &mut bool,
|
||
audio_controller: Option<&std::sync::Arc<std::sync::Mutex<daw_backend::EngineController>>>,
|
||
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
||
editing_clip_id: Option<&uuid::Uuid>,
|
||
audio_cache: &HashMap<uuid::Uuid, Vec<ClipInstance>>,
|
||
) {
|
||
// Only allocate content area (ruler + layers) with click and drag
|
||
let content_response = ui.allocate_rect(
|
||
egui::Rect::from_min_size(
|
||
egui::pos2(content_rect.min.x, ruler_rect.min.y),
|
||
egui::vec2(
|
||
content_rect.width(),
|
||
ruler_rect.height() + content_rect.height()
|
||
)
|
||
),
|
||
egui::Sense::click_and_drag()
|
||
);
|
||
|
||
let response = content_response;
|
||
|
||
// Check if mouse is over either area
|
||
let header_hovered = ui.rect_contains_pointer(header_rect);
|
||
let any_hovered = response.hovered() || header_hovered;
|
||
|
||
// Only process input if mouse is over the timeline pane
|
||
if !any_hovered {
|
||
self.is_panning = false;
|
||
self.last_pan_pos = None;
|
||
self.is_scrubbing = false;
|
||
return;
|
||
}
|
||
|
||
let alt_held = ui.input(|i| i.modifiers.alt);
|
||
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
||
let shift_held = ui.input(|i| i.modifiers.shift);
|
||
|
||
// Handle clip instance selection by clicking on clip rectangles
|
||
let mut clicked_clip_instance = false;
|
||
if response.clicked() && !alt_held {
|
||
if let Some(pos) = response.interact_pointer_pos() {
|
||
// Check if click is in content area (not ruler or header column)
|
||
if pos.y >= header_rect.min.y && pos.x >= content_rect.min.x {
|
||
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||
// Get the layer at this index (using virtual rows for group support)
|
||
let click_rows = build_timeline_rows(context_layers);
|
||
if clicked_layer_index < click_rows.len() {
|
||
let click_row = &click_rows[clicked_layer_index];
|
||
// Check collapsed groups first (merged spans)
|
||
if matches!(click_row, TimelineRow::CollapsedGroup { .. }) {
|
||
if let Some(child_ids) = self.detect_collapsed_group_at_pointer(
|
||
pos, document, content_rect, header_rect, editing_clip_id,
|
||
) {
|
||
if !child_ids.is_empty() {
|
||
if shift_held {
|
||
for id in &child_ids {
|
||
selection.add_clip_instance(*id);
|
||
}
|
||
} else {
|
||
selection.clear_clip_instances();
|
||
for id in &child_ids {
|
||
selection.add_clip_instance(*id);
|
||
}
|
||
}
|
||
*active_layer_id = Some(click_row.layer_id());
|
||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||
clicked_clip_instance = true;
|
||
}
|
||
}
|
||
} else if let Some(layer) = click_row.as_any_layer() {
|
||
// Normal or GroupChild rows: check individual clips
|
||
let _layer_data = layer.layer();
|
||
|
||
// Get clip instances for this layer
|
||
let clip_instances = layer_clips(layer, &audio_cache);
|
||
|
||
// Check if click is within any clip instance
|
||
let click_stacking = compute_clip_stacking(document, layer, clip_instances);
|
||
let click_layer_top = pos.y - (relative_y % LAYER_HEIGHT);
|
||
for (ci_idx, clip_instance) in clip_instances.iter().enumerate() {
|
||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||
|
||
if let Some(clip_duration) = clip_duration {
|
||
let instance_duration = clip_instance.total_duration(clip_duration, document.tempo_map());
|
||
let instance_start = clip_instance.effective_start();
|
||
let instance_end = instance_start + instance_duration;
|
||
|
||
// Check if click is within this clip instance's pixel range and vertical bounds
|
||
let ci_start_x = self.time_to_x(instance_start);
|
||
let ci_end_x = self.time_to_x(instance_end).max(ci_start_x + MIN_CLIP_WIDTH_PX);
|
||
let click_x = pos.x - content_rect.min.x;
|
||
let (row, total_rows) = click_stacking[ci_idx];
|
||
let (cy_min, cy_max) = clip_instance_y_bounds(row, total_rows);
|
||
let click_rel_y = pos.y - click_layer_top;
|
||
if click_x >= ci_start_x && click_x <= ci_end_x
|
||
&& click_rel_y >= cy_min && click_rel_y <= cy_max
|
||
{
|
||
// Found a clicked clip instance!
|
||
if shift_held {
|
||
// Shift+click: add to selection
|
||
selection.add_clip_instance(clip_instance.id);
|
||
} else {
|
||
// Regular click: select only this clip
|
||
selection.select_only_clip_instance(clip_instance.id);
|
||
}
|
||
// Also set this layer as the active layer
|
||
*active_layer_id = Some(layer.id());
|
||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||
clicked_clip_instance = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Layer header drag-to-reorder (manual pointer tracking, no allocate_rect)
|
||
let pointer_pos = ui.input(|i| i.pointer.hover_pos());
|
||
let primary_down = ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary));
|
||
let primary_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
|
||
let primary_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
|
||
|
||
// Handle layer header selection on mousedown (immediate, not on release)
|
||
if primary_pressed && !alt_held && !self.layer_control_clicked {
|
||
if let Some(pos) = pointer_pos {
|
||
if header_rect.contains(pos) {
|
||
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||
let header_rows = build_timeline_rows(context_layers);
|
||
if clicked_layer_index < header_rows.len() {
|
||
let layer_id = header_rows[clicked_layer_index].layer_id();
|
||
let clicked_parent = header_rows[clicked_layer_index].parent_id();
|
||
let prev_active = *active_layer_id;
|
||
*active_layer_id = Some(layer_id);
|
||
if shift_held {
|
||
// If focus doesn't already contain the previously active layer
|
||
// (e.g. it was set by creating a layer rather than clicking),
|
||
// seed the selection with it so shift-click extends from it.
|
||
if let Some(prev) = prev_active.filter(|id| *id != layer_id) {
|
||
let active_in_focus = matches!(
|
||
&focus,
|
||
lightningbeam_core::selection::FocusSelection::Layers(ids) if ids.contains(&prev)
|
||
);
|
||
if !active_in_focus {
|
||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![prev]);
|
||
}
|
||
}
|
||
shift_toggle_layer(focus, layer_id, clicked_parent, &header_rows);
|
||
} else {
|
||
// Only change selection if the clicked layer isn't already selected
|
||
let already_selected = match focus {
|
||
lightningbeam_core::selection::FocusSelection::Layers(ids) => ids.contains(&layer_id),
|
||
_ => false,
|
||
};
|
||
if !already_selected {
|
||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
|
||
}
|
||
}
|
||
}
|
||
// Also record for potential drag
|
||
self.header_mousedown_pos = Some(pos);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Start drag after movement threshold (4px)
|
||
const LAYER_DRAG_THRESHOLD: f32 = 4.0;
|
||
if self.layer_drag.is_none() && !self.layer_control_clicked {
|
||
if let (Some(down_pos), Some(cur_pos)) = (self.header_mousedown_pos, pointer_pos) {
|
||
if primary_down && (cur_pos - down_pos).length() > LAYER_DRAG_THRESHOLD {
|
||
let relative_y = down_pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||
let clicked_index = (relative_y / LAYER_HEIGHT) as usize;
|
||
let drag_rows = build_timeline_rows(context_layers);
|
||
if clicked_index < drag_rows.len() {
|
||
// Collect all selected layer IDs (in visual order)
|
||
let selected_ids: Vec<uuid::Uuid> = match focus {
|
||
lightningbeam_core::selection::FocusSelection::Layers(ids) => {
|
||
// Filter to only IDs present in the row list, in visual order
|
||
drag_rows.iter()
|
||
.filter(|r| ids.contains(&r.layer_id()))
|
||
.map(|r| r.layer_id())
|
||
.collect()
|
||
}
|
||
_ => vec![drag_rows[clicked_index].layer_id()],
|
||
};
|
||
// If clicked layer isn't in selection, just drag that one
|
||
let clicked_id = drag_rows[clicked_index].layer_id();
|
||
let layer_ids = if selected_ids.contains(&clicked_id) {
|
||
selected_ids
|
||
} else {
|
||
vec![clicked_id]
|
||
};
|
||
|
||
// Find source parent IDs for each dragged layer
|
||
let source_parent_ids: Vec<Option<uuid::Uuid>> = layer_ids.iter()
|
||
.map(|lid| drag_rows.iter().find(|r| r.layer_id() == *lid).and_then(|r| r.parent_id()))
|
||
.collect();
|
||
|
||
// Find the visual index of the first dragged layer
|
||
let first_drag_visual_idx = drag_rows.iter()
|
||
.position(|r| r.layer_id() == layer_ids[0])
|
||
.unwrap_or(0);
|
||
|
||
// Compute gap index in the filtered list
|
||
let gap_index = drag_rows.iter()
|
||
.take(first_drag_visual_idx)
|
||
.filter(|r| !layer_ids.contains(&r.layer_id()))
|
||
.count();
|
||
|
||
// Grab offset: ensure the clicked layer stays under the cursor
|
||
// in the stacked floating header view
|
||
let clicked_row_y = header_rect.min.y + clicked_index as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
||
let clicked_within_drag = layer_ids.iter().position(|id| *id == clicked_id).unwrap_or(0);
|
||
let grab_offset = down_pos.y - clicked_row_y + clicked_within_drag as f32 * LAYER_HEIGHT;
|
||
|
||
self.layer_drag = Some(LayerDragState {
|
||
layer_ids,
|
||
source_parent_ids,
|
||
gap_row_index: gap_index,
|
||
current_mouse_y: cur_pos.y,
|
||
grab_offset_y: grab_offset,
|
||
});
|
||
}
|
||
self.header_mousedown_pos = None; // consumed
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update gap position and mouse Y during layer drag
|
||
if let Some(ref mut drag) = self.layer_drag {
|
||
if primary_down {
|
||
if let Some(pos) = pointer_pos {
|
||
drag.current_mouse_y = pos.y;
|
||
let relative_y = pos.y - drag.grab_offset_y - header_rect.min.y + self.viewport_scroll_y + LAYER_HEIGHT * 0.5;
|
||
let all_rows = build_timeline_rows(context_layers);
|
||
let filtered_count = all_rows.iter()
|
||
.filter(|r| !drag.layer_ids.contains(&r.layer_id()))
|
||
.count();
|
||
let target = ((relative_y / LAYER_HEIGHT) as usize).min(filtered_count);
|
||
drag.gap_row_index = target;
|
||
}
|
||
ui.ctx().request_repaint();
|
||
}
|
||
}
|
||
|
||
// Drop layers on mouse release
|
||
if self.layer_drag.is_some() && primary_released {
|
||
let drag = self.layer_drag.take().unwrap();
|
||
|
||
// Build the row list to determine where the gap lands
|
||
let drop_rows = build_timeline_rows(context_layers);
|
||
let filtered_rows: Vec<&TimelineRow> = drop_rows.iter()
|
||
.filter(|r| !drag.layer_ids.contains(&r.layer_id()))
|
||
.collect();
|
||
|
||
// Determine target parent from the row above the gap
|
||
let new_parent_id = if drag.gap_row_index == 0 {
|
||
None // top of list = root
|
||
} else {
|
||
let row_above = &filtered_rows[drag.gap_row_index.min(filtered_rows.len()) - 1];
|
||
row_above.parent_id()
|
||
};
|
||
|
||
// Compute insertion index in new parent's children vec AFTER dragged layers are removed.
|
||
// Get the new parent's children, filter out all dragged layers, find where the
|
||
// row-above falls in that filtered list.
|
||
let new_children: Vec<uuid::Uuid> = match new_parent_id {
|
||
None => context_layers.iter().map(|l| l.id()).collect(),
|
||
Some(pid) => {
|
||
if let Some(AnyLayer::Group(g)) = document.root.get_child(&pid) {
|
||
g.children.iter().map(|l| l.id()).collect()
|
||
} else {
|
||
vec![]
|
||
}
|
||
}
|
||
};
|
||
let new_children_filtered: Vec<uuid::Uuid> = new_children.iter()
|
||
.filter(|id| !drag.layer_ids.contains(id))
|
||
.copied()
|
||
.collect();
|
||
|
||
let new_base_index = if drag.gap_row_index == 0 {
|
||
// Gap at top = visually topmost position.
|
||
// Since timeline reverses children, this is the end of the children vec.
|
||
new_children_filtered.len()
|
||
} else {
|
||
let row_above = &filtered_rows[drag.gap_row_index.min(filtered_rows.len()) - 1];
|
||
let above_id = row_above.layer_id();
|
||
if let Some(pos) = new_children_filtered.iter().position(|&id| id == above_id) {
|
||
// Insert before it in children vec (visually below = lower children index)
|
||
pos
|
||
} else {
|
||
new_children_filtered.len()
|
||
}
|
||
};
|
||
|
||
// Build layer list: (layer_id, old_parent_id) in visual order
|
||
let layers: Vec<(uuid::Uuid, Option<uuid::Uuid>)> = drag.layer_ids.iter()
|
||
.zip(drag.source_parent_ids.iter())
|
||
.map(|(id, pid)| (*id, *pid))
|
||
.collect();
|
||
|
||
// Only create action if something actually changed
|
||
let anything_changed = layers.iter().enumerate().any(|(i, (lid, old_pid))| {
|
||
if *old_pid != new_parent_id {
|
||
return true;
|
||
}
|
||
// Check if position changed within same parent
|
||
let old_idx = new_children.iter().position(|id| id == lid);
|
||
let target_idx_in_original = if new_base_index < new_children_filtered.len() {
|
||
// Find where new_children_filtered[new_base_index] sits in original
|
||
new_children.iter().position(|id| *id == new_children_filtered[new_base_index])
|
||
.map(|p| p + i)
|
||
} else {
|
||
Some(0 + i) // inserting at start of children (end of filtered = start of original)
|
||
};
|
||
old_idx != target_idx_in_original
|
||
});
|
||
|
||
if anything_changed {
|
||
pending_actions.push(Box::new(
|
||
lightningbeam_core::actions::MoveLayerAction::new(
|
||
layers,
|
||
new_parent_id,
|
||
new_base_index,
|
||
),
|
||
));
|
||
}
|
||
}
|
||
|
||
// Clear header mousedown if released without starting a drag
|
||
if primary_released {
|
||
self.header_mousedown_pos = None;
|
||
}
|
||
// Cancel layer drag if pointer is no longer down
|
||
if self.layer_drag.is_some() && !primary_down {
|
||
self.layer_drag = None;
|
||
}
|
||
|
||
// Cache mouse position on mousedown (before any dragging)
|
||
if response.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) {
|
||
if let Some(pos) = response.hover_pos() {
|
||
self.mousedown_pos = Some(pos);
|
||
}
|
||
}
|
||
|
||
// Handle clip dragging (only if not panning or scrubbing)
|
||
if !alt_held && !self.is_scrubbing && !self.is_panning {
|
||
if response.drag_started() {
|
||
// Use cached mousedown position for edge detection
|
||
if let Some(mousedown_pos) = self.mousedown_pos {
|
||
if let Some((drag_type, clip_id)) = self.detect_clip_at_pointer(
|
||
mousedown_pos,
|
||
document,
|
||
content_rect,
|
||
header_rect,
|
||
editing_clip_id,
|
||
&audio_cache,
|
||
) {
|
||
// If this clip is not selected, select it (respecting shift key)
|
||
if !selection.contains_clip_instance(&clip_id) {
|
||
if shift_held {
|
||
selection.add_clip_instance(clip_id);
|
||
} else {
|
||
selection.select_only_clip_instance(clip_id);
|
||
}
|
||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||
}
|
||
|
||
// Start dragging with the detected drag type
|
||
self.clip_drag_state = Some(drag_type);
|
||
self.drag_offset = 0.0;
|
||
if drag_type == ClipDragType::Move {
|
||
// Find earliest selected clip as snap anchor for quantized moves
|
||
let mut earliest = f64::MAX;
|
||
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||
for ci in clip_instances {
|
||
if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest {
|
||
earliest = ci.timeline_start;
|
||
}
|
||
}
|
||
}
|
||
self.drag_anchor_start = if earliest == f64::MAX { 0.0 } else { earliest };
|
||
}
|
||
} else if let Some(child_ids) = self.detect_collapsed_group_at_pointer(
|
||
mousedown_pos,
|
||
document,
|
||
content_rect,
|
||
header_rect,
|
||
editing_clip_id,
|
||
) {
|
||
// Collapsed group merged span — select all child clips and start Move drag
|
||
if !child_ids.is_empty() {
|
||
if !shift_held {
|
||
selection.clear_clip_instances();
|
||
}
|
||
for id in &child_ids {
|
||
selection.add_clip_instance(*id);
|
||
}
|
||
*focus = lightningbeam_core::selection::FocusSelection::ClipInstances(selection.clip_instances().to_vec());
|
||
self.clip_drag_state = Some(ClipDragType::Move);
|
||
self.drag_offset = 0.0;
|
||
// Find earliest selected clip as snap anchor
|
||
let mut earliest = f64::MAX;
|
||
for (_, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||
for ci in clip_instances {
|
||
if selection.contains_clip_instance(&ci.id) && ci.timeline_start < earliest {
|
||
earliest = ci.timeline_start;
|
||
}
|
||
}
|
||
}
|
||
self.drag_anchor_start = if earliest == f64::MAX { 0.0 } else { earliest };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update drag offset during drag
|
||
if self.clip_drag_state.is_some() && response.dragged() {
|
||
let drag_delta = response.drag_delta();
|
||
let time_delta = drag_delta.x / self.pixels_per_second;
|
||
self.drag_offset += time_delta as f64;
|
||
}
|
||
|
||
// End drag - create action based on drag type
|
||
if let Some(drag_type) = self.clip_drag_state {
|
||
if response.drag_stopped() {
|
||
// Build layer_moves map for the action
|
||
use std::collections::HashMap;
|
||
let mut layer_moves: HashMap<uuid::Uuid, Vec<(uuid::Uuid, f64, f64)>> =
|
||
HashMap::new();
|
||
|
||
// Compute snapped offset once for all selected clips (preserves relative spacing)
|
||
let move_offset = self.snapped_move_offset(document.tempo_map(), &document.time_signature, document.framerate);
|
||
|
||
// Iterate through all layers (including group children) to find selected clip instances
|
||
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||
let layer_id = layer.id();
|
||
// Find selected clip instances in this layer
|
||
for clip_instance in clip_instances {
|
||
if selection.contains_clip_instance(&clip_instance.id) {
|
||
let old_timeline_start = clip_instance.timeline_start;
|
||
let new_timeline_start = old_timeline_start + move_offset;
|
||
|
||
// Add to layer_moves
|
||
layer_moves
|
||
.entry(layer_id)
|
||
.or_insert_with(Vec::new)
|
||
.push((clip_instance.id, old_timeline_start, new_timeline_start));
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create and add the action based on drag type
|
||
match drag_type {
|
||
ClipDragType::Move => {
|
||
if !layer_moves.is_empty() {
|
||
let action = Box::new(
|
||
lightningbeam_core::actions::MoveClipInstancesAction::new(
|
||
layer_moves,
|
||
),
|
||
);
|
||
pending_actions.push(action);
|
||
}
|
||
}
|
||
ClipDragType::TrimLeft | ClipDragType::TrimRight => {
|
||
// Build layer_trims map for trim action
|
||
let mut layer_trims: HashMap<
|
||
uuid::Uuid,
|
||
Vec<(
|
||
uuid::Uuid,
|
||
lightningbeam_core::actions::TrimType,
|
||
lightningbeam_core::actions::TrimData,
|
||
lightningbeam_core::actions::TrimData,
|
||
)>,
|
||
> = HashMap::new();
|
||
|
||
// Iterate through all layers (including group children) to find selected clip instances
|
||
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||
let layer_id = layer.id();
|
||
|
||
// Find selected clip instances in this layer
|
||
for clip_instance in clip_instances {
|
||
if selection.contains_clip_instance(&clip_instance.id) {
|
||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||
|
||
if let Some(clip_duration) = clip_duration {
|
||
match drag_type {
|
||
ClipDragType::TrimLeft => {
|
||
let old_trim_start = clip_instance.trim_start;
|
||
let old_timeline_start =
|
||
clip_instance.timeline_start;
|
||
|
||
// New trim_start is snapped then clamped to valid range
|
||
let desired_trim_start = self.snap_to_grid(
|
||
old_trim_start + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
|
||
).max(0.0).min(clip_duration);
|
||
|
||
// Apply overlap prevention when extending left
|
||
let new_trim_start = if desired_trim_start < old_trim_start {
|
||
let max_extend = document.find_max_trim_extend_left(
|
||
&layer_id,
|
||
&clip_instance.id,
|
||
old_timeline_start,
|
||
);
|
||
let desired_extend = old_trim_start - desired_trim_start;
|
||
let actual_extend = desired_extend.min(max_extend);
|
||
old_trim_start - actual_extend
|
||
} else {
|
||
desired_trim_start
|
||
};
|
||
|
||
// Calculate actual offset after clamping
|
||
let actual_offset = new_trim_start - old_trim_start;
|
||
let new_timeline_start =
|
||
old_timeline_start + actual_offset;
|
||
|
||
layer_trims
|
||
.entry(layer_id)
|
||
.or_insert_with(Vec::new)
|
||
.push((
|
||
clip_instance.id,
|
||
lightningbeam_core::actions::TrimType::TrimLeft,
|
||
lightningbeam_core::actions::TrimData::left(
|
||
old_trim_start,
|
||
old_timeline_start,
|
||
),
|
||
lightningbeam_core::actions::TrimData::left(
|
||
new_trim_start,
|
||
new_timeline_start,
|
||
),
|
||
));
|
||
}
|
||
ClipDragType::TrimRight => {
|
||
let old_trim_end = clip_instance.trim_end;
|
||
|
||
// Calculate new trim_end based on current duration
|
||
let current_duration =
|
||
clip_instance.effective_duration(clip_duration, document.tempo_map());
|
||
let old_trim_end_val = clip_instance.trim_end.unwrap_or(clip_duration);
|
||
let desired_trim_end = self.snap_to_grid(
|
||
old_trim_end_val + self.drag_offset, document.tempo_map(), &document.time_signature, document.framerate,
|
||
).max(clip_instance.trim_start).min(clip_duration);
|
||
|
||
// Apply overlap prevention when extending right
|
||
let new_trim_end_val = if desired_trim_end > old_trim_end_val {
|
||
let max_extend = document.find_max_trim_extend_right(
|
||
&layer_id,
|
||
&clip_instance.id,
|
||
clip_instance.timeline_start,
|
||
current_duration,
|
||
);
|
||
let desired_extend = desired_trim_end - old_trim_end_val;
|
||
let actual_extend = desired_extend.min(max_extend);
|
||
old_trim_end_val + actual_extend
|
||
} else {
|
||
desired_trim_end
|
||
};
|
||
|
||
let new_duration = (new_trim_end_val - clip_instance.trim_start).max(0.0);
|
||
|
||
// Convert new duration back to trim_end value
|
||
let new_trim_end = if new_duration >= clip_duration {
|
||
None // Use full clip duration
|
||
} else {
|
||
Some((clip_instance.trim_start + new_duration).min(clip_duration))
|
||
};
|
||
|
||
layer_trims
|
||
.entry(layer_id)
|
||
.or_insert_with(Vec::new)
|
||
.push((
|
||
clip_instance.id,
|
||
lightningbeam_core::actions::TrimType::TrimRight,
|
||
lightningbeam_core::actions::TrimData::right(
|
||
old_trim_end,
|
||
),
|
||
lightningbeam_core::actions::TrimData::right(
|
||
new_trim_end,
|
||
),
|
||
));
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Create and add the trim action if there are any trims
|
||
if !layer_trims.is_empty() {
|
||
let action = Box::new(
|
||
lightningbeam_core::actions::TrimClipInstancesAction::new(
|
||
layer_trims,
|
||
),
|
||
);
|
||
pending_actions.push(action);
|
||
}
|
||
}
|
||
ClipDragType::LoopExtendRight => {
|
||
let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
|
||
|
||
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||
let layer_id = layer.id();
|
||
|
||
for clip_instance in clip_instances {
|
||
if selection.contains_clip_instance(&clip_instance.id) {
|
||
let clip_duration = match layer {
|
||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||
document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||
}
|
||
_ => continue,
|
||
};
|
||
|
||
if let Some(clip_duration) = clip_duration {
|
||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||
let content_window = (trim_end - clip_instance.trim_start).max(0.0);
|
||
let current_right = clip_instance.timeline_duration.unwrap_or(content_window);
|
||
let right_edge = clip_instance.timeline_start + current_right + self.drag_offset;
|
||
let snapped_edge = self.snap_to_grid(right_edge, document.tempo_map(), &document.time_signature, document.framerate);
|
||
let desired_right = snapped_edge - clip_instance.timeline_start;
|
||
|
||
let new_right = if desired_right > current_right {
|
||
let max_extend = document.find_max_trim_extend_right(
|
||
&layer_id,
|
||
&clip_instance.id,
|
||
clip_instance.timeline_start,
|
||
current_right,
|
||
);
|
||
let extend_amount = (desired_right - current_right).min(max_extend);
|
||
current_right + extend_amount
|
||
} else {
|
||
desired_right
|
||
};
|
||
|
||
let old_timeline_duration = clip_instance.timeline_duration;
|
||
let new_timeline_duration = if new_right > content_window + 0.001 {
|
||
Some(new_right)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if old_timeline_duration != new_timeline_duration {
|
||
layer_loops
|
||
.entry(layer_id)
|
||
.or_insert_with(Vec::new)
|
||
.push((
|
||
clip_instance.id,
|
||
old_timeline_duration,
|
||
new_timeline_duration,
|
||
clip_instance.loop_before,
|
||
clip_instance.loop_before, // loop_before unchanged
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !layer_loops.is_empty() {
|
||
let action = Box::new(
|
||
lightningbeam_core::actions::LoopClipInstancesAction::new(layer_loops),
|
||
);
|
||
pending_actions.push(action);
|
||
}
|
||
}
|
||
ClipDragType::LoopExtendLeft => {
|
||
// Extend loop_before (pre-loop region)
|
||
let mut layer_loops: HashMap<uuid::Uuid, Vec<lightningbeam_core::actions::loop_clip_instances::LoopEntry>> = HashMap::new();
|
||
|
||
for (layer, clip_instances) in all_layer_clip_instances(context_layers, &audio_cache) {
|
||
let layer_id = layer.id();
|
||
|
||
for clip_instance in clip_instances {
|
||
if selection.contains_clip_instance(&clip_instance.id) {
|
||
let clip_duration = match layer {
|
||
lightningbeam_core::layer::AnyLayer::Audio(_) => {
|
||
document.get_audio_clip(&clip_instance.clip_id).map(|c| c.duration)
|
||
}
|
||
_ => continue,
|
||
};
|
||
|
||
if let Some(clip_duration) = clip_duration {
|
||
let trim_end = clip_instance.trim_end.unwrap_or(clip_duration);
|
||
let content_window = (trim_end - clip_instance.trim_start).max(0.001);
|
||
let current_loop_before = clip_instance.loop_before.unwrap_or(0.0);
|
||
// Invert: dragging left (negative offset) = extend
|
||
let desired_loop_before = (current_loop_before - self.drag_offset).max(0.0);
|
||
// Snap to whole iterations so backend modulo aligns
|
||
let desired_iters = (desired_loop_before / content_window).round();
|
||
let snapped = desired_iters * content_window;
|
||
|
||
let new_loop_before = if snapped > current_loop_before {
|
||
let max_extend = document.find_max_loop_extend_left(
|
||
&layer_id,
|
||
&clip_instance.id,
|
||
clip_instance.effective_start(),
|
||
);
|
||
let extend_amount = (snapped - current_loop_before).min(max_extend);
|
||
let clamped = current_loop_before + extend_amount;
|
||
(clamped / content_window).floor() * content_window
|
||
} else {
|
||
snapped
|
||
};
|
||
|
||
let old_loop_before = clip_instance.loop_before;
|
||
let new_lb = if new_loop_before > 0.001 {
|
||
Some(new_loop_before)
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if old_loop_before != new_lb {
|
||
layer_loops
|
||
.entry(layer_id)
|
||
.or_insert_with(Vec::new)
|
||
.push((
|
||
clip_instance.id,
|
||
clip_instance.timeline_duration,
|
||
clip_instance.timeline_duration, // timeline_duration unchanged
|
||
old_loop_before,
|
||
new_lb,
|
||
));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
if !layer_loops.is_empty() {
|
||
let action = Box::new(
|
||
lightningbeam_core::actions::LoopClipInstancesAction::new(layer_loops),
|
||
);
|
||
pending_actions.push(action);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Reset drag state
|
||
self.clip_drag_state = None;
|
||
self.drag_offset = 0.0;
|
||
self.mousedown_pos = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle layer selection by clicking on layer header or content (only if no clip was clicked)
|
||
if response.clicked() && !alt_held && !clicked_clip_instance {
|
||
if let Some(pos) = response.interact_pointer_pos() {
|
||
// Check if click is in header or content area (not ruler)
|
||
if pos.y >= header_rect.min.y {
|
||
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||
|
||
// Get the layer at this index (using virtual rows for group support)
|
||
let empty_click_rows = build_timeline_rows(context_layers);
|
||
if clicked_layer_index < empty_click_rows.len() {
|
||
let layer_id = empty_click_rows[clicked_layer_index].layer_id();
|
||
let clicked_parent = empty_click_rows[clicked_layer_index].parent_id();
|
||
let prev_active = *active_layer_id;
|
||
*active_layer_id = Some(layer_id);
|
||
if shift_held {
|
||
if let Some(prev) = prev_active.filter(|id| *id != layer_id) {
|
||
let active_in_focus = matches!(
|
||
&focus,
|
||
lightningbeam_core::selection::FocusSelection::Layers(ids) if ids.contains(&prev)
|
||
);
|
||
if !active_in_focus {
|
||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![prev]);
|
||
}
|
||
}
|
||
shift_toggle_layer(focus, layer_id, clicked_parent, &empty_click_rows);
|
||
} else {
|
||
selection.clear_clip_instances();
|
||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Pointing-hand cursor when hovering a clickable keyframe diamond.
|
||
if let Some(hover) = response.hover_pos() {
|
||
if self.keyframe_diamond_hits.iter().any(|(r, _)| r.contains(hover)) {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||
}
|
||
}
|
||
|
||
// Click a keyframe diamond → snap the playhead exactly to that keyframe.
|
||
// (Hit targets come from the previous frame's render_layers; diamonds don't
|
||
// move between frames, so a click lands on the right one.)
|
||
if response.clicked() && !alt_held {
|
||
if let Some(pos) = response.interact_pointer_pos() {
|
||
if let Some(&(_, kf_time)) =
|
||
self.keyframe_diamond_hits.iter().find(|(r, _)| r.contains(pos))
|
||
{
|
||
*playback_time = kf_time;
|
||
if let Some(controller_arc) = audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(kf_time);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Get mouse position relative to content area
|
||
let mouse_pos = response.hover_pos().unwrap_or(content_rect.center());
|
||
let mouse_x = (mouse_pos.x - content_rect.min.x).max(0.0);
|
||
|
||
// Calculate max vertical scroll based on number of layers
|
||
let total_content_height = layer_count as f32 * LAYER_HEIGHT;
|
||
let visible_height = content_rect.height();
|
||
let max_scroll_y = (total_content_height - visible_height).max(0.0);
|
||
|
||
// Scrubbing (clicking/dragging on ruler, but only when not panning)
|
||
let cursor_over_ruler = ruler_rect.contains(ui.input(|i| i.pointer.hover_pos().unwrap_or_default()));
|
||
|
||
// Start scrubbing if cursor is over ruler and we click/drag
|
||
if cursor_over_ruler && !alt_held && (response.clicked() || (response.dragged() && !self.is_panning)) {
|
||
if let Some(pos) = response.interact_pointer_pos() {
|
||
let x = (pos.x - content_rect.min.x).max(0.0);
|
||
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate);
|
||
*playback_time = new_time;
|
||
self.is_scrubbing = true;
|
||
// Seek immediately so it works while playing
|
||
if let Some(controller_arc) = audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(new_time);
|
||
}
|
||
}
|
||
}
|
||
// Continue scrubbing while dragging, even if cursor leaves ruler
|
||
else if self.is_scrubbing && response.dragged() && !self.is_panning {
|
||
if let Some(pos) = response.interact_pointer_pos() {
|
||
let x = (pos.x - content_rect.min.x).max(0.0);
|
||
let new_time = self.snap_to_grid(self.x_to_time(x).max(0.0), document.tempo_map(), &document.time_signature, document.framerate);
|
||
*playback_time = new_time;
|
||
if let Some(controller_arc) = audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(new_time);
|
||
}
|
||
}
|
||
}
|
||
// Stop scrubbing when drag ends
|
||
else if !response.dragged() && self.is_scrubbing {
|
||
self.is_scrubbing = false;
|
||
}
|
||
|
||
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
||
// Only handle scroll when mouse is over the timeline area
|
||
let mut handled = false;
|
||
let pointer_over_timeline = response.hovered() || ui.rect_contains_pointer(header_rect);
|
||
if pointer_over_timeline { ui.input(|i| {
|
||
for event in &i.raw.events {
|
||
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
|
||
match unit {
|
||
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
|
||
// Real mouse wheel (discrete clicks) -> always zoom horizontally
|
||
let zoom_delta = if ctrl_held || modifiers.ctrl {
|
||
delta.y * 0.01 // Ctrl+wheel: faster zoom
|
||
} else {
|
||
delta.y * 0.005 // Normal zoom
|
||
};
|
||
self.apply_zoom_at_point(zoom_delta, mouse_x);
|
||
handled = true;
|
||
}
|
||
egui::MouseWheelUnit::Point => {
|
||
// Trackpad (smooth scrolling)
|
||
if ctrl_held || modifiers.ctrl {
|
||
// Ctrl held: zoom
|
||
let zoom_delta = delta.y * 0.005;
|
||
self.apply_zoom_at_point(zoom_delta, mouse_x);
|
||
handled = true;
|
||
}
|
||
// Otherwise let scroll_delta handle panning (below)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}); }
|
||
|
||
// Handle scroll_delta for trackpad panning (when Ctrl not held)
|
||
if pointer_over_timeline && !handled {
|
||
let scroll_delta = ui.input(|i| i.smooth_scroll_delta);
|
||
if scroll_delta.x.abs() > 0.0 || scroll_delta.y.abs() > 0.0 {
|
||
// Horizontal scroll: pan timeline (inverted: positive delta scrolls left/earlier in time)
|
||
let delta_time = scroll_delta.x / self.pixels_per_second;
|
||
self.viewport_start_time = (self.viewport_start_time - delta_time as f64).max(0.0);
|
||
|
||
// Vertical scroll: scroll layers vertically (clamped to content bounds)
|
||
self.viewport_scroll_y = (self.viewport_scroll_y - scroll_delta.y).clamp(0.0, max_scroll_y);
|
||
}
|
||
}
|
||
|
||
// Handle panning with Alt+Drag (timeline scrolls left/right, layers scroll up/down)
|
||
if alt_held && response.dragged() && !self.is_scrubbing {
|
||
if let Some(last_pos) = self.last_pan_pos {
|
||
if let Some(current_pos) = response.interact_pointer_pos() {
|
||
let delta = current_pos - last_pos;
|
||
|
||
// Horizontal pan: timeline
|
||
let delta_time = delta.x / self.pixels_per_second;
|
||
self.viewport_start_time = (self.viewport_start_time - delta_time as f64).max(0.0);
|
||
|
||
// Vertical pan: layers (clamped to content bounds)
|
||
self.viewport_scroll_y = (self.viewport_scroll_y - delta.y).clamp(0.0, max_scroll_y);
|
||
}
|
||
}
|
||
self.last_pan_pos = response.interact_pointer_pos();
|
||
self.is_panning = true;
|
||
} else {
|
||
if !response.dragged() {
|
||
self.is_panning = false;
|
||
self.last_pan_pos = None;
|
||
}
|
||
}
|
||
|
||
// Update cursor based on hover position (only if not scrubbing or panning)
|
||
if !self.is_scrubbing && !self.is_panning {
|
||
// If dragging a clip with trim/loop, keep the appropriate cursor
|
||
if let Some(drag_type) = self.clip_drag_state {
|
||
match drag_type {
|
||
ClipDragType::TrimLeft | ClipDragType::TrimRight => {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||
}
|
||
ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => {
|
||
crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend);
|
||
}
|
||
ClipDragType::Move => {}
|
||
}
|
||
} else if let Some(hover_pos) = response.hover_pos() {
|
||
// Not dragging - detect hover for cursor feedback
|
||
if let Some((drag_type, _clip_id)) = self.detect_clip_at_pointer(
|
||
hover_pos,
|
||
document,
|
||
content_rect,
|
||
header_rect,
|
||
editing_clip_id,
|
||
&audio_cache,
|
||
) {
|
||
match drag_type {
|
||
ClipDragType::TrimLeft | ClipDragType::TrimRight => {
|
||
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
|
||
}
|
||
ClipDragType::LoopExtendRight | ClipDragType::LoopExtendLeft => {
|
||
crate::custom_cursor::set(ui.ctx(), crate::custom_cursor::CustomCursor::LoopExtend);
|
||
}
|
||
ClipDragType::Move => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
impl PaneRenderer for TimelinePane {
|
||
fn render_header(&mut self, ui: &mut egui::Ui, shared: &mut SharedPaneState) -> bool {
|
||
// Sync timeline mode from document (document is source of truth)
|
||
self.time_display_format = shared.action_executor.document().timeline_mode;
|
||
|
||
// Fire deferred recording commands once count-in pre-roll has elapsed
|
||
self.check_pending_recording_start(shared);
|
||
|
||
ui.spacing_mut().item_spacing.x = 2.0; // Small spacing between button groups
|
||
|
||
// Main playback controls group
|
||
ui.group(|ui| {
|
||
ui.horizontal(|ui| {
|
||
ui.spacing_mut().item_spacing.x = 0.0; // No spacing between buttons
|
||
let button_size = egui::vec2(32.0, 28.0); // Larger buttons
|
||
|
||
// Go to start
|
||
if ui.add_sized(button_size, egui::Button::new("|◀")).clicked() {
|
||
*shared.playback_time = 0.0;
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(0.0);
|
||
}
|
||
}
|
||
|
||
// Rewind (step backward)
|
||
if ui.add_sized(button_size, egui::Button::new("◀◀")).clicked() {
|
||
*shared.playback_time = (*shared.playback_time - 0.1).max(0.0);
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(*shared.playback_time);
|
||
}
|
||
}
|
||
|
||
// Play/Pause toggle
|
||
let play_pause_text = if *shared.is_playing { "⏸" } else { "▶" };
|
||
if ui.add_sized(button_size, egui::Button::new(play_pause_text)).clicked() {
|
||
// If pausing while recording, stop recording first
|
||
if *shared.is_playing && *shared.is_recording {
|
||
self.stop_recording(shared);
|
||
println!("⏹ Stopped recording (playback paused)");
|
||
}
|
||
|
||
*shared.is_playing = !*shared.is_playing;
|
||
println!("🔘 Play/Pause button clicked! is_playing = {}", *shared.is_playing);
|
||
|
||
// Send play/pause command to audio engine
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
if *shared.is_playing {
|
||
controller.play();
|
||
println!("▶ Started playback");
|
||
} else {
|
||
controller.pause();
|
||
println!("⏸ Paused playback");
|
||
}
|
||
} else {
|
||
println!("⚠️ No audio controller available (audio system failed to initialize)");
|
||
}
|
||
}
|
||
|
||
// Fast forward (step forward)
|
||
if ui.add_sized(button_size, egui::Button::new("▶▶")).clicked() {
|
||
*shared.playback_time = (*shared.playback_time + 0.1).min(self.duration);
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(*shared.playback_time);
|
||
}
|
||
}
|
||
|
||
// Go to end
|
||
if ui.add_sized(button_size, egui::Button::new("▶|")).clicked() {
|
||
*shared.playback_time = self.duration;
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.seek(self.duration);
|
||
}
|
||
}
|
||
|
||
// Small separator before record button
|
||
ui.add_space(8.0);
|
||
|
||
// Record button - red circle, pulsing when recording
|
||
let record_color = if *shared.is_recording {
|
||
// Pulsing red when recording (vary alpha based on time)
|
||
let pulse = (ui.ctx().input(|i| i.time) * 2.0).sin() * 0.3 + 0.7;
|
||
egui::Color32::from_rgba_unmultiplied(220, 50, 50, (pulse * 255.0) as u8)
|
||
} else {
|
||
egui::Color32::from_rgb(180, 60, 60)
|
||
};
|
||
|
||
let record_button = egui::Button::new(
|
||
egui::RichText::new("⏺").color(record_color).size(16.0)
|
||
);
|
||
|
||
if ui.add_sized(button_size, record_button).clicked() {
|
||
self.toggle_recording(shared);
|
||
}
|
||
|
||
// Request repaint while recording for pulse animation
|
||
if *shared.is_recording {
|
||
ui.ctx().request_repaint();
|
||
}
|
||
|
||
// Metronome toggle — only visible in Measures mode
|
||
if self.time_display_format == TimelineMode::Measures {
|
||
ui.add_space(4.0);
|
||
|
||
let metro_tint = if *shared.metronome_enabled {
|
||
egui::Color32::from_rgb(100, 180, 255)
|
||
} else {
|
||
ui.visuals().text_color()
|
||
};
|
||
|
||
// Lazy-load the metronome SVG icon
|
||
if self.metronome_icon.is_none() {
|
||
const METRONOME_SVG: &[u8] = include_bytes!("../../../../src/assets/metronome.svg");
|
||
self.metronome_icon = crate::rasterize_svg(METRONOME_SVG, "metronome_icon", 64, ui.ctx());
|
||
}
|
||
|
||
let metro_response = if let Some(icon) = &self.metronome_icon {
|
||
let (rect, response) = ui.allocate_exact_size(button_size, egui::Sense::click());
|
||
let bg = if *shared.metronome_enabled {
|
||
egui::Color32::from_rgba_unmultiplied(100, 180, 255, 60)
|
||
} else if response.hovered() {
|
||
ui.visuals().widgets.hovered.bg_fill
|
||
} else {
|
||
egui::Color32::TRANSPARENT
|
||
};
|
||
ui.painter().rect_filled(rect, 4.0, bg);
|
||
ui.painter().image(
|
||
icon.id(),
|
||
rect.shrink(4.0),
|
||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||
metro_tint,
|
||
);
|
||
response
|
||
} else {
|
||
// Fallback if SVG failed to load
|
||
ui.add_sized(button_size, egui::Button::new(
|
||
egui::RichText::new("♩").color(metro_tint).size(16.0)
|
||
))
|
||
};
|
||
|
||
let metro_response = metro_response.on_hover_text(if *shared.metronome_enabled {
|
||
"Disable metronome"
|
||
} else {
|
||
"Enable metronome"
|
||
});
|
||
|
||
if metro_response.clicked() {
|
||
*shared.metronome_enabled = !*shared.metronome_enabled;
|
||
// Sync live state if already recording
|
||
if *shared.is_recording {
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.set_metronome_enabled(*shared.metronome_enabled);
|
||
}
|
||
}
|
||
}
|
||
|
||
}
|
||
});
|
||
});
|
||
|
||
ui.separator();
|
||
|
||
// Get text color from theme
|
||
let text_style = shared.theme.style(".text-primary", ui.ctx());
|
||
let text_color = text_style.text_color.unwrap_or(egui::Color32::from_gray(200));
|
||
|
||
// Time display (format-dependent)
|
||
{
|
||
let (tempo_map, bpm, time_sig_num, time_sig_den, framerate) = {
|
||
let doc = shared.action_executor.document();
|
||
(doc.tempo_map().clone(), doc.bpm(), doc.time_signature.numerator, doc.time_signature.denominator, doc.framerate)
|
||
};
|
||
|
||
match self.time_display_format {
|
||
TimelineMode::Seconds => {
|
||
ui.colored_label(text_color, format!("Time: {:.2}s / {:.2}s", *shared.playback_time, self.duration));
|
||
}
|
||
TimelineMode::Measures => {
|
||
let time_sig = lightningbeam_core::document::TimeSignature { numerator: time_sig_num, denominator: time_sig_den };
|
||
let pos = lightningbeam_core::beat_time::time_to_measure(
|
||
*shared.playback_time, &tempo_map, &time_sig,
|
||
);
|
||
ui.colored_label(text_color, format!(
|
||
"BAR: {}.{} | BPM: {:.0} | {}/{}",
|
||
pos.measure, pos.beat, bpm,
|
||
time_sig_num, time_sig_den,
|
||
));
|
||
}
|
||
TimelineMode::Frames => {
|
||
let current_frame = (*shared.playback_time * framerate).floor() as i64 + 1;
|
||
let total_frames = (self.duration * framerate).ceil() as i64;
|
||
ui.colored_label(text_color, format!(
|
||
"Frame: {} / {} | {:.0} FPS", current_frame, total_frames, framerate
|
||
));
|
||
}
|
||
}
|
||
|
||
ui.separator();
|
||
|
||
// Zoom display
|
||
ui.colored_label(text_color, format!("Zoom: {:.0}px/s", self.pixels_per_second));
|
||
|
||
ui.separator();
|
||
|
||
// Time display format toggle
|
||
egui::ComboBox::from_id_salt("time_format")
|
||
.selected_text(match self.time_display_format {
|
||
TimelineMode::Seconds => "Seconds",
|
||
TimelineMode::Measures => "Measures",
|
||
TimelineMode::Frames => "Frames",
|
||
})
|
||
.width(80.0)
|
||
.show_ui(ui, |ui| {
|
||
ui.selectable_value(&mut self.time_display_format, TimelineMode::Seconds, "Seconds");
|
||
ui.selectable_value(&mut self.time_display_format, TimelineMode::Measures, "Measures");
|
||
ui.selectable_value(&mut self.time_display_format, TimelineMode::Frames, "Frames");
|
||
});
|
||
// Write change back to document so it persists and is the source of truth
|
||
shared.action_executor.document_mut().timeline_mode = self.time_display_format;
|
||
|
||
ui.separator();
|
||
|
||
// Stereo mix output VU meter (two stacked bars: L on top, R on bottom)
|
||
{
|
||
let meter_width = 80.0;
|
||
let meter_height = 14.0; // total height for both bars + gap
|
||
let bar_height = 6.0;
|
||
let gap = 2.0;
|
||
let (meter_rect, _) = ui.allocate_exact_size(
|
||
egui::vec2(meter_width, meter_height),
|
||
egui::Sense::hover(),
|
||
);
|
||
// Background
|
||
ui.painter().rect_filled(meter_rect, 2.0, shared.theme.bg_color(&["#timeline", ".vu-meter-bg"], ui.ctx(), egui::Color32::from_gray(30)));
|
||
|
||
let levels = [shared.output_level.0.min(1.0), shared.output_level.1.min(1.0)];
|
||
for (i, &level) in levels.iter().enumerate() {
|
||
let bar_y = meter_rect.min.y + i as f32 * (bar_height + gap);
|
||
if level > 0.001 {
|
||
let filled_width = meter_rect.width() * level;
|
||
let color = if level > 0.9 {
|
||
shared.theme.bg_color(&["#timeline", ".vu-meter", ".clip"], ui.ctx(), egui::Color32::from_rgb(220, 50, 50))
|
||
} else if level > 0.7 {
|
||
shared.theme.bg_color(&["#timeline", ".vu-meter", ".warn"], ui.ctx(), egui::Color32::from_rgb(220, 200, 50))
|
||
} else {
|
||
shared.theme.bg_color(&["#timeline", ".vu-meter", ".normal"], ui.ctx(), egui::Color32::from_rgb(50, 200, 80))
|
||
};
|
||
let filled_rect = egui::Rect::from_min_size(
|
||
egui::pos2(meter_rect.min.x, bar_y),
|
||
egui::vec2(filled_width, bar_height),
|
||
);
|
||
ui.painter().rect_filled(filled_rect, 1.0, color);
|
||
}
|
||
}
|
||
}
|
||
|
||
ui.separator();
|
||
|
||
// BPM control — disabled when tempo automation has been edited (multiple entries)
|
||
let tempo_is_automated = tempo_map.entries.len() > 1;
|
||
let mut bpm_val = bpm;
|
||
ui.label("BPM:");
|
||
let bpm_response = ui.add_enabled(
|
||
!tempo_is_automated,
|
||
egui::DragValue::new(&mut bpm_val)
|
||
.range(20.0..=300.0)
|
||
.speed(0.5)
|
||
.fixed_decimals(1),
|
||
);
|
||
|
||
if !tempo_is_automated {
|
||
// Capture start BPM on drag/focus start
|
||
if bpm_response.gained_focus() || bpm_response.drag_started() {
|
||
if self.bpm_drag_start.is_none() {
|
||
self.bpm_drag_start = Some(bpm);
|
||
}
|
||
}
|
||
|
||
if bpm_response.changed() {
|
||
// Fallback capture: if gained_focus/drag_started didn't fire (e.g. rapid input),
|
||
// capture start BPM on the first change before updating the document.
|
||
if self.bpm_drag_start.is_none() {
|
||
self.bpm_drag_start = Some(bpm);
|
||
}
|
||
// Live preview: update document directly so grid reflows immediately
|
||
shared.action_executor.document_mut().set_bpm(bpm_val);
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.set_tempo(bpm_val as f32, (time_sig_num, time_sig_den));
|
||
}
|
||
}
|
||
|
||
// On commit, dispatch a single ChangeBpmAction (single undo entry)
|
||
if bpm_response.drag_stopped() || bpm_response.lost_focus() {
|
||
if let Some(start_bpm) = self.bpm_drag_start.take() {
|
||
let new_bpm = shared.action_executor.document().bpm();
|
||
if (start_bpm - new_bpm).abs() > 1e-6
|
||
&& self.time_display_format == lightningbeam_core::document::TimelineMode::Measures
|
||
{
|
||
use lightningbeam_core::actions::ChangeBpmAction;
|
||
// document.bpm() is already new_bpm from the live preview — build action
|
||
// with new_bpm so it records the correct undo state.
|
||
let action = ChangeBpmAction::new(
|
||
new_bpm,
|
||
shared.action_executor.document(),
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
ui.separator();
|
||
|
||
// Time signature selector
|
||
let time_sig_presets: [(u32, u32); 8] = [
|
||
(2, 4), (3, 4), (4, 4), (5, 4),
|
||
(6, 8), (7, 8), (9, 8), (12, 8),
|
||
];
|
||
let current_ts_label = format!("{}/{}", time_sig_num, time_sig_den);
|
||
egui::ComboBox::from_id_salt("time_sig")
|
||
.selected_text(¤t_ts_label)
|
||
.width(60.0)
|
||
.show_ui(ui, |ui| {
|
||
for (num, den) in &time_sig_presets {
|
||
let label = format!("{}/{}", num, den);
|
||
if ui.selectable_label(
|
||
time_sig_num == *num && time_sig_den == *den,
|
||
&label,
|
||
).clicked() {
|
||
let doc = shared.action_executor.document_mut();
|
||
doc.time_signature.numerator = *num;
|
||
doc.time_signature.denominator = *den;
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
controller.set_tempo(doc.bpm() as f32, (*num, *den));
|
||
}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
true
|
||
}
|
||
|
||
fn render_content(
|
||
&mut self,
|
||
ui: &mut egui::Ui,
|
||
rect: egui::Rect,
|
||
_path: &NodePath,
|
||
shared: &mut SharedPaneState,
|
||
) {
|
||
// Reset layer control click flag at start of frame
|
||
self.layer_control_clicked = false;
|
||
|
||
// Sync playback_time to document
|
||
shared.action_executor.document_mut().current_time = *shared.playback_time;
|
||
|
||
// Get document from action executor
|
||
let document = shared.action_executor.document();
|
||
let editing_clip_id = shared.editing_clip_id;
|
||
let mut context_layers = document.context_layers(editing_clip_id.as_ref());
|
||
// Prepend master track as the first row when enabled (only at root context, not inside clips)
|
||
let _master_any_layer;
|
||
if self.show_master_track && editing_clip_id.is_none() {
|
||
_master_any_layer = Some(AnyLayer::Group(document.master_layer.clone()));
|
||
context_layers.insert(0, _master_any_layer.as_ref().unwrap());
|
||
}
|
||
// Use virtual row count (includes expanded group children) for height calculations
|
||
let layer_count = build_timeline_rows(&context_layers).len();
|
||
|
||
// Build audio clip cache from backend snapshot (backend-as-source-of-truth for audio).
|
||
// Uses doc UUIDs via reverse lookup of clip_instance_to_backend_map so that selection
|
||
// and action dispatch continue to work with doc UUIDs.
|
||
// Falls back to AudioLayer::clip_instances for layers with no snapshot data yet
|
||
// (e.g., layers where recording is in progress but not yet finalized).
|
||
let audio_cache: HashMap<uuid::Uuid, Vec<ClipInstance>> =
|
||
if let Some(snap_arc) = shared.clip_snapshot.as_ref() {
|
||
if let Ok(snap) = snap_arc.read() {
|
||
build_audio_clip_cache(
|
||
&snap,
|
||
shared.layer_to_track_map,
|
||
document,
|
||
shared.clip_instance_to_backend_map,
|
||
)
|
||
} else {
|
||
HashMap::new()
|
||
}
|
||
} else {
|
||
HashMap::new()
|
||
};
|
||
|
||
// Calculate project duration from last clip endpoint across all layers
|
||
let mut max_endpoint: f64 = 10.0; // Default minimum duration
|
||
for &layer in &context_layers {
|
||
let clip_instances = layer_clips(layer, &audio_cache);
|
||
|
||
for clip_instance in clip_instances {
|
||
let clip_duration = effective_clip_duration(document, layer, clip_instance);
|
||
|
||
if let Some(clip_duration) = clip_duration {
|
||
let instance_duration = clip_instance.effective_duration(clip_duration, document.tempo_map());
|
||
let instance_end = clip_instance.timeline_start + instance_duration;
|
||
max_endpoint = max_endpoint.max(instance_end);
|
||
}
|
||
}
|
||
}
|
||
self.duration = max_endpoint;
|
||
|
||
// Split into layer header column (left) and timeline content (right)
|
||
let header_column_rect = egui::Rect::from_min_size(
|
||
rect.min,
|
||
egui::vec2(LAYER_HEADER_WIDTH, rect.height()),
|
||
);
|
||
|
||
let timeline_rect = egui::Rect::from_min_size(
|
||
rect.min + egui::vec2(LAYER_HEADER_WIDTH, 0.0),
|
||
egui::vec2(rect.width() - LAYER_HEADER_WIDTH, rect.height()),
|
||
);
|
||
|
||
// Split timeline into ruler and content areas
|
||
let ruler_rect = egui::Rect::from_min_size(
|
||
timeline_rect.min,
|
||
egui::vec2(timeline_rect.width(), RULER_HEIGHT),
|
||
);
|
||
|
||
let content_rect = egui::Rect::from_min_size(
|
||
timeline_rect.min + egui::vec2(0.0, RULER_HEIGHT),
|
||
egui::vec2(timeline_rect.width(), timeline_rect.height() - RULER_HEIGHT),
|
||
);
|
||
|
||
// Split header column into ruler area (top) and layer headers (bottom)
|
||
let header_ruler_spacer = egui::Rect::from_min_size(
|
||
header_column_rect.min,
|
||
egui::vec2(LAYER_HEADER_WIDTH, RULER_HEIGHT),
|
||
);
|
||
|
||
let layer_headers_rect = egui::Rect::from_min_size(
|
||
header_column_rect.min + egui::vec2(0.0, RULER_HEIGHT),
|
||
egui::vec2(LAYER_HEADER_WIDTH, header_column_rect.height() - RULER_HEIGHT),
|
||
);
|
||
|
||
// Save original clip rect to restore at the end
|
||
let original_clip_rect = ui.clip_rect();
|
||
|
||
// Render spacer above layer headers (same height as ruler)
|
||
let spacer_style = shared.theme.style(".timeline-spacer", ui.ctx());
|
||
let spacer_bg = spacer_style.background_color().unwrap_or(egui::Color32::from_rgb(17, 17, 17));
|
||
ui.painter().rect_filled(
|
||
header_ruler_spacer,
|
||
0.0,
|
||
spacer_bg,
|
||
);
|
||
|
||
// Render layer header column with clipping
|
||
ui.set_clip_rect(layer_headers_rect.intersect(original_clip_rect));
|
||
self.render_layer_headers(ui, layer_headers_rect, shared.theme, shared.active_layer_id, shared.focus, &mut shared.pending_actions, document, &context_layers, shared.layer_to_track_map, shared.track_levels, shared.input_level, *shared.playback_time);
|
||
|
||
// Render time ruler (clip to ruler rect)
|
||
ui.set_clip_rect(ruler_rect.intersect(original_clip_rect));
|
||
self.render_ruler(ui, ruler_rect, shared.theme, document.tempo_map(), &document.time_signature, document.framerate);
|
||
|
||
// Render layer rows with clipping
|
||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||
let (video_clip_hovers, pending_lane_renders) = self.render_layers(ui, content_rect, shared.theme, document, shared.active_layer_id, shared.focus, shared.selection, shared.midi_event_cache, shared.raw_audio_cache, shared.waveform_gpu_dirty, shared.waveform_minmax_pools, shared.target_format, shared.waveform_stereo, &context_layers, shared.video_manager, &audio_cache, *shared.playback_time);
|
||
|
||
// Render playhead on top (clip to timeline area)
|
||
ui.set_clip_rect(timeline_rect.intersect(original_clip_rect));
|
||
self.render_playhead(ui, timeline_rect, shared.theme, *shared.playback_time);
|
||
|
||
// Restore original clip rect
|
||
ui.set_clip_rect(original_clip_rect);
|
||
|
||
// Process pending automation lane edit actions
|
||
if !self.pending_automation_actions.is_empty() {
|
||
let actions = std::mem::take(&mut self.pending_automation_actions);
|
||
let tempo_map = document.tempo_map().clone();
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
for action in actions {
|
||
match action {
|
||
AutomationLaneAction::AddKeyframe { layer_id, node_id, time, value } => {
|
||
if node_id == MASTER_TEMPO_NODE_ID {
|
||
// Tempo lane: `time` is beats (curve editor uses beat-based closures)
|
||
let old_map = tempo_map.clone();
|
||
let beat = time;
|
||
let mut new_map = old_map.clone();
|
||
new_map.entries.push(daw_backend::TempoEntry { beat, bpm: value as f64, seconds: 0.0, interpolation: daw_backend::TempoInterpolation::Linear });
|
||
new_map.entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap_or(std::cmp::Ordering::Equal));
|
||
new_map.rebuild_seconds();
|
||
let bpm_action = lightningbeam_core::actions::ChangeBpmAction::from_maps(old_map, new_map);
|
||
shared.pending_actions.push(Box::new(bpm_action));
|
||
// Drop cache so it rebuilds (from updated tempo_map) after action executes
|
||
self.automation_cache.remove(&layer_id);
|
||
} else if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||
// time is already in beats (all automation x-axes use beats)
|
||
controller.automation_add_keyframe(track_id, node_id, time, value, "linear".to_string(), (0.0, 0.0), (0.0, 0.0));
|
||
// Optimistic cache update (beats)
|
||
if let Some(lanes) = self.automation_cache.get_mut(&layer_id) {
|
||
if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) {
|
||
if let Some(existing) = lane.keyframes.iter_mut().find(|k| (k.time - time).abs() < 0.001) {
|
||
existing.value = value;
|
||
} else {
|
||
lane.keyframes.push(crate::curve_editor::CurvePoint {
|
||
time,
|
||
value,
|
||
interpolation: crate::curve_editor::CurveInterpolation::Linear,
|
||
ease_out: (0.0, 0.0),
|
||
ease_in: (0.0, 0.0),
|
||
});
|
||
lane.keyframes.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AutomationLaneAction::MoveKeyframe { layer_id, node_id, old_time, new_time, new_value, interpolation, ease_out, ease_in } => {
|
||
if node_id == MASTER_TEMPO_NODE_ID {
|
||
// Tempo lane: old_time/new_time are beats (beat-based closures)
|
||
let old_beat = old_time;
|
||
let new_beat = new_time;
|
||
let old_map = tempo_map.clone();
|
||
let mut new_map = old_map.clone();
|
||
if old_beat > 1e-9 {
|
||
// Non-anchor entry: remove old, add at new position (preserve interpolation)
|
||
let orig_interp = old_map.entries.iter()
|
||
.find(|e| (e.beat - old_beat).abs() < 1e-9)
|
||
.map(|e| e.interpolation.clone())
|
||
.unwrap_or(daw_backend::TempoInterpolation::Linear);
|
||
new_map.entries.retain(|e| (e.beat - old_beat).abs() >= 1e-9);
|
||
new_map.entries.push(daw_backend::TempoEntry { beat: new_beat.max(1e-9), bpm: new_value as f64, seconds: 0.0, interpolation: orig_interp });
|
||
} else {
|
||
// Beat-0 anchor: only the BPM value can change, position is fixed at beat 0
|
||
if let Some(e) = new_map.entries.iter_mut().find(|e| e.beat < 1e-9) {
|
||
e.bpm = new_value as f64;
|
||
}
|
||
}
|
||
new_map.entries.sort_by(|a, b| a.beat.partial_cmp(&b.beat).unwrap_or(std::cmp::Ordering::Equal));
|
||
new_map.rebuild_seconds();
|
||
let bpm_action = lightningbeam_core::actions::ChangeBpmAction::from_maps(old_map, new_map);
|
||
shared.pending_actions.push(Box::new(bpm_action));
|
||
self.automation_cache.remove(&layer_id);
|
||
} else if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||
// old_time / new_time are already in beats
|
||
controller.automation_remove_keyframe(track_id, node_id, old_time);
|
||
controller.automation_add_keyframe(track_id, node_id, new_time, new_value, interpolation.clone(), ease_out, ease_in);
|
||
// Optimistic cache update (beats)
|
||
if let Some(lanes) = self.automation_cache.get_mut(&layer_id) {
|
||
if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) {
|
||
if let Some(kf) = lane.keyframes.iter_mut().find(|k| (k.time - old_time).abs() < 0.001) {
|
||
kf.time = new_time;
|
||
kf.value = new_value;
|
||
}
|
||
lane.keyframes.sort_by(|a, b| a.time.partial_cmp(&b.time).unwrap_or(std::cmp::Ordering::Equal));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AutomationLaneAction::DeleteKeyframe { layer_id, node_id, time } => {
|
||
if node_id == MASTER_TEMPO_NODE_ID {
|
||
// Tempo lane: time is beats; never remove beat-0 anchor
|
||
let old_map = tempo_map.clone();
|
||
let beat = time;
|
||
if beat > 1e-9 {
|
||
let mut new_map = old_map.clone();
|
||
new_map.entries.retain(|e| (e.beat - beat).abs() >= 1e-9);
|
||
new_map.rebuild_seconds();
|
||
let bpm_action = lightningbeam_core::actions::ChangeBpmAction::from_maps(old_map, new_map);
|
||
shared.pending_actions.push(Box::new(bpm_action));
|
||
self.automation_cache.remove(&layer_id);
|
||
}
|
||
} else if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||
// time is already in beats
|
||
controller.automation_remove_keyframe(track_id, node_id, time);
|
||
// Optimistic cache update (beats)
|
||
if let Some(lanes) = self.automation_cache.get_mut(&layer_id) {
|
||
if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) {
|
||
lane.keyframes.retain(|k| (k.time - time).abs() >= 0.001);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
AutomationLaneAction::SetRange { layer_id, node_id, min, max } => {
|
||
if node_id != MASTER_TEMPO_NODE_ID {
|
||
if let Some(&track_id) = shared.layer_to_track_map.get(&layer_id) {
|
||
controller.automation_set_range(track_id, node_id, min, max);
|
||
// Optimistic cache update
|
||
if let Some(lanes) = self.automation_cache.get_mut(&layer_id) {
|
||
if let Some(lane) = lanes.iter_mut().find(|l| l.node_id == node_id) {
|
||
lane.value_min = min;
|
||
lane.value_max = max;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Invalidate automation cache when the project changes (new node added, etc.)
|
||
if *shared.project_generation != self.automation_cache_generation {
|
||
self.automation_cache_generation = *shared.project_generation;
|
||
self.automation_cache.clear();
|
||
}
|
||
|
||
// Invalidate the master layer's tempo lane cache when BPM changes.
|
||
// Use `automation_cache_bpm` as a sentinel: if the document's global BPM differs from
|
||
// what was cached, drop the master layer entry so it rebuilds with the new tempo_map.
|
||
// We skip this during live BPM drag (bpm_drag_start.is_some()) to avoid flickering.
|
||
let bpm_committed = self.bpm_drag_start.is_none();
|
||
if bpm_committed && (document.bpm() - self.automation_cache_bpm).abs() > 1e-9 {
|
||
self.automation_cache_bpm = document.bpm();
|
||
let master_layer_id_for_bpm = document.master_layer.layer.id;
|
||
self.automation_cache.remove(&master_layer_id_for_bpm);
|
||
}
|
||
|
||
// Refresh automation cache for expanded layers.
|
||
// Clears all caches when the project is reloaded (project_generation) or when the node
|
||
// graph topology changes (graph_topology_generation — bumped by the node graph pane on
|
||
// any successful add/remove/connect action).
|
||
let topology_changed = *shared.graph_topology_generation != self.automation_topology_generation;
|
||
if topology_changed {
|
||
self.automation_topology_generation = *shared.graph_topology_generation;
|
||
self.automation_cache.clear();
|
||
}
|
||
let master_layer_id = document.master_layer.layer.id;
|
||
let tempo_map_clone = document.tempo_map().clone();
|
||
for layer_id in self.automation_expanded.iter().copied().collect::<Vec<_>>() {
|
||
if !self.automation_cache.contains_key(&layer_id) {
|
||
if let Some(controller_arc) = shared.audio_controller {
|
||
let mut controller = controller_arc.lock().unwrap();
|
||
self.refresh_automation_cache(layer_id, &mut controller, shared.layer_to_track_map, master_layer_id, &tempo_map_clone);
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle input (use full rect including header column)
|
||
self.handle_input(
|
||
ui,
|
||
rect,
|
||
ruler_rect,
|
||
content_rect,
|
||
layer_headers_rect,
|
||
layer_count,
|
||
document,
|
||
shared.active_layer_id,
|
||
shared.selection,
|
||
shared.focus,
|
||
shared.pending_actions,
|
||
shared.playback_time,
|
||
shared.is_playing,
|
||
shared.audio_controller,
|
||
&context_layers,
|
||
editing_clip_id.as_ref(),
|
||
&audio_cache,
|
||
);
|
||
|
||
// Render automation lanes AFTER handle_input so our ui.interact registers last and wins
|
||
// egui's interaction priority over handle_input's full-content-area allocation.
|
||
// All automation lanes use beats as the x-axis; convert via the tempo map.
|
||
ui.set_clip_rect(content_rect.intersect(original_clip_rect));
|
||
let tempo_map_for_lanes = document.tempo_map().clone();
|
||
for lane in &pending_lane_renders {
|
||
let drag_key = (lane.layer_id, lane.node_id);
|
||
let mut drag_state_local = self.automation_drag
|
||
.get(&drag_key)
|
||
.and_then(|v| v.clone());
|
||
let lane_id = egui::Id::new("automation_lane")
|
||
.with(lane.layer_id)
|
||
.with(lane.node_id);
|
||
let lane_min_x = lane.lane_rect.min.x;
|
||
let tm = &tempo_map_for_lanes;
|
||
// playback_time is seconds; convert to beats for the beat-axis curve editor
|
||
let playback_beats = tm.inverse_transform(lane.playback_time);
|
||
let action = crate::curve_editor::render_curve_lane(
|
||
ui,
|
||
lane.lane_rect,
|
||
&lane.keyframes,
|
||
&mut drag_state_local,
|
||
playback_beats,
|
||
lane.accent_color,
|
||
lane_id,
|
||
lane.value_min,
|
||
lane.value_max,
|
||
|beat| lane_min_x + self.time_to_x(tm.transform(beat)),
|
||
|x| tm.inverse_transform(self.x_to_time(x - lane_min_x)),
|
||
);
|
||
self.automation_drag.insert(drag_key, drag_state_local);
|
||
let layer_id = lane.layer_id;
|
||
let node_id = lane.node_id;
|
||
let keyframes = &lane.keyframes;
|
||
match action {
|
||
crate::curve_editor::CurveEditAction::AddKeyframe { time, value } => {
|
||
self.pending_automation_actions.push(AutomationLaneAction::AddKeyframe {
|
||
layer_id, node_id, time, value,
|
||
});
|
||
}
|
||
crate::curve_editor::CurveEditAction::MoveKeyframe { index, new_time, new_value } => {
|
||
if let Some(kf) = keyframes.get(index) {
|
||
self.pending_automation_actions.push(AutomationLaneAction::MoveKeyframe {
|
||
layer_id, node_id,
|
||
old_time: kf.time, new_time, new_value,
|
||
interpolation: match kf.interpolation {
|
||
crate::curve_editor::CurveInterpolation::Bezier => "bezier".to_string(),
|
||
crate::curve_editor::CurveInterpolation::Step => "step".to_string(),
|
||
crate::curve_editor::CurveInterpolation::Hold => "hold".to_string(),
|
||
_ => "linear".to_string(),
|
||
},
|
||
ease_out: kf.ease_out,
|
||
ease_in: kf.ease_in,
|
||
});
|
||
}
|
||
}
|
||
crate::curve_editor::CurveEditAction::DeleteKeyframe { index } => {
|
||
if let Some(kf) = keyframes.get(index) {
|
||
self.pending_automation_actions.push(AutomationLaneAction::DeleteKeyframe {
|
||
layer_id, node_id, time: kf.time,
|
||
});
|
||
}
|
||
}
|
||
crate::curve_editor::CurveEditAction::None => {}
|
||
}
|
||
}
|
||
ui.set_clip_rect(original_clip_rect);
|
||
|
||
// 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, editing_clip_id.as_ref(), &audio_cache) {
|
||
// Right-clicked on a clip
|
||
if !shared.selection.contains_clip_instance(&clip_id) {
|
||
shared.selection.select_only_clip_instance(clip_id);
|
||
}
|
||
*shared.focus = lightningbeam_core::selection::FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
|
||
self.context_menu_clip = Some((Some(clip_id), pos));
|
||
} else {
|
||
// Right-clicked on empty timeline space
|
||
self.context_menu_clip = Some((None, pos));
|
||
}
|
||
just_opened_menu = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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 = has_clip && {
|
||
let mut enabled = false;
|
||
if let Some(layer_id) = *shared.active_layer_id {
|
||
if let Some(layer) = document.get_layer(&layer_id) {
|
||
let instances = layer_clips(layer, &audio_cache);
|
||
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, document.tempo_map());
|
||
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 = has_clip && {
|
||
let mut enabled = false;
|
||
if let Some(layer_id) = *shared.active_layer_id {
|
||
if let Some(layer) = document.get_layer(&layer_id) {
|
||
let instances = layer_clips(layer, &audio_cache);
|
||
// 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, document.tempo_map());
|
||
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
|
||
};
|
||
|
||
// 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.tempo_map());
|
||
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)
|
||
.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", has_clip) {
|
||
shared.pending_menu_actions.push(crate::menu::MenuAction::Cut);
|
||
item_clicked = 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", has_clip) {
|
||
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
|
||
// This ensures hover events aren't consumed by the main input handler
|
||
for (clip_rect, clip_id, trim_start, clip_origin_x) in video_clip_hovers {
|
||
let hover_response = ui.allocate_rect(clip_rect, egui::Sense::hover());
|
||
|
||
if hover_response.hovered() {
|
||
if let Some(hover_pos) = hover_response.hover_pos() {
|
||
// Content time from the clip's TRUE origin. `clip_rect` is clamped
|
||
// to the viewport, so using its left edge mislabels the frame when
|
||
// the clip is scrolled partly off the left (same bug the strip had).
|
||
let hover_offset_pixels = hover_pos.x - clip_origin_x;
|
||
let clip_content_time = trim_start + (hover_offset_pixels as f64) / (self.pixels_per_second as f64);
|
||
|
||
// Try to get thumbnail from video manager
|
||
let thumbnail_data: Option<(f64, u32, u32, std::sync::Arc<Vec<u8>>)> = {
|
||
let video_mgr = shared.video_manager.lock().unwrap();
|
||
video_mgr.get_thumbnail_at(&clip_id, clip_content_time)
|
||
};
|
||
|
||
if let Some((_, thumb_width, thumb_height, ref thumb_data)) = thumbnail_data {
|
||
// Create texture from thumbnail
|
||
let color_image = egui::ColorImage::from_rgba_unmultiplied(
|
||
[thumb_width as usize, thumb_height as usize],
|
||
&thumb_data,
|
||
);
|
||
let texture = ui.ctx().load_texture(
|
||
format!("video_hover_{}", clip_id),
|
||
color_image,
|
||
egui::TextureOptions::LINEAR,
|
||
);
|
||
|
||
// Show tooltip with thumbnail positioned near cursor
|
||
let tooltip_pos = hover_pos + egui::vec2(10.0, 10.0);
|
||
egui::Area::new(egui::Id::new(format!("video_hover_tooltip_{}", clip_id)))
|
||
.fixed_pos(tooltip_pos)
|
||
.order(egui::Order::Tooltip)
|
||
.show(ui.ctx(), |ui| {
|
||
egui::Frame::popup(ui.style())
|
||
.show(ui, |ui| {
|
||
ui.vertical(|ui| {
|
||
ui.image(&texture);
|
||
ui.label(format!("Time: {:.2}s", clip_content_time));
|
||
});
|
||
});
|
||
});
|
||
} else {
|
||
// Show simple tooltip if no thumbnail available
|
||
let tooltip_pos = hover_pos + egui::vec2(10.0, 10.0);
|
||
egui::Area::new(egui::Id::new(format!("video_tooltip_{}", clip_id)))
|
||
.fixed_pos(tooltip_pos)
|
||
.order(egui::Order::Tooltip)
|
||
.show(ui.ctx(), |ui| {
|
||
egui::Frame::popup(ui.style())
|
||
.show(ui, |ui| {
|
||
ui.label(format!("Video clip\nTime: {:.2}s\n(Thumbnails generating...)", clip_content_time));
|
||
});
|
||
});
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Handle asset drag-and-drop from Asset Library
|
||
if let Some(dragging) = shared.dragging_asset.as_ref() {
|
||
if let Some(pointer_pos) = ui.ctx().pointer_interact_pos() {
|
||
// Check if pointer is in content area (not ruler or header column)
|
||
if content_rect.contains(pointer_pos) {
|
||
// Calculate which layer the pointer is over
|
||
let relative_y = pointer_pos.y - content_rect.min.y + self.viewport_scroll_y;
|
||
// Get the layer at this index (using virtual rows for group support)
|
||
let drop_rows = build_timeline_rows(&context_layers);
|
||
let hovered_layer_index = self.row_at_y(&drop_rows, relative_y).map(|(i, _)| i).unwrap_or(usize::MAX);
|
||
|
||
let drop_layer = drop_rows.get(hovered_layer_index).and_then(|r| r.as_any_layer());
|
||
if let Some(layer) = drop_layer {
|
||
let is_compatible = can_drop_on_layer(layer, dragging.clip_type);
|
||
|
||
// Visual feedback: highlight compatible tracks
|
||
let layer_y = content_rect.min.y + hovered_layer_index as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
||
let highlight_rect = egui::Rect::from_min_size(
|
||
egui::pos2(content_rect.min.x, layer_y),
|
||
egui::vec2(content_rect.width(), LAYER_HEIGHT),
|
||
);
|
||
|
||
let highlight_color = if is_compatible {
|
||
egui::Color32::from_rgba_unmultiplied(100, 255, 100, 40) // Green
|
||
} else {
|
||
egui::Color32::from_rgba_unmultiplied(255, 100, 100, 40) // Red
|
||
};
|
||
|
||
ui.painter().rect_filled(highlight_rect, 0.0, highlight_color);
|
||
|
||
// Show drop time indicator with snap preview
|
||
let raw_drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0);
|
||
|
||
// Calculate snapped drop time for preview
|
||
let drop_time = if is_compatible {
|
||
// Get clip duration to calculate snapped position
|
||
let clip_duration = {
|
||
let doc = shared.action_executor.document();
|
||
doc.get_clip_duration(&dragging.clip_id).unwrap_or(1.0)
|
||
};
|
||
|
||
// Find nearest valid position (auto-snap for preview)
|
||
let snapped = shared.action_executor.document()
|
||
.find_nearest_valid_position(
|
||
&layer.id(),
|
||
raw_drop_time,
|
||
clip_duration,
|
||
&[],
|
||
);
|
||
|
||
snapped.unwrap_or(raw_drop_time)
|
||
} else {
|
||
raw_drop_time
|
||
};
|
||
|
||
let drop_x = self.time_to_x(drop_time);
|
||
if drop_x >= 0.0 && drop_x <= content_rect.width() {
|
||
ui.painter().line_segment(
|
||
[
|
||
egui::pos2(content_rect.min.x + drop_x, layer_y),
|
||
egui::pos2(content_rect.min.x + drop_x, layer_y + LAYER_HEIGHT),
|
||
],
|
||
egui::Stroke::new(2.0, egui::Color32::WHITE),
|
||
);
|
||
}
|
||
|
||
// Handle drop on mouse release
|
||
if ui.input(|i| i.pointer.any_released()) && is_compatible {
|
||
let layer_id = layer.id();
|
||
let drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0);
|
||
|
||
// Handle effect drops specially
|
||
if dragging.clip_type == DragClipType::Effect {
|
||
// Get effect definition from registry or document
|
||
let effect_def = lightningbeam_core::effect_registry::EffectRegistry::get_by_id(&dragging.clip_id)
|
||
.or_else(|| shared.action_executor.document().get_effect_definition(&dragging.clip_id).cloned());
|
||
|
||
if let Some(def) = effect_def {
|
||
// Ensure effect definition is in document (copy from registry if built-in)
|
||
if shared.action_executor.document().get_effect_definition(&def.id).is_none() {
|
||
shared.action_executor.document_mut().add_effect_definition(def.clone());
|
||
}
|
||
|
||
// Create clip instance for effect with 5 second default duration
|
||
let clip_instance = ClipInstance::new(def.id)
|
||
.with_timeline_start(drop_time)
|
||
.with_timeline_duration(5.0);
|
||
|
||
// Use AddEffectAction for effect layers
|
||
let action = lightningbeam_core::actions::AddEffectAction::new(
|
||
layer_id,
|
||
clip_instance,
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
|
||
// Clear drag state
|
||
*shared.dragging_asset = None;
|
||
} else {
|
||
// Get document dimensions for centering and create clip instance
|
||
let (_center_x, _center_y, clip_instance) = {
|
||
let doc = shared.action_executor.document();
|
||
let center_x = doc.width / 2.0;
|
||
let center_y = doc.height / 2.0;
|
||
|
||
let mut clip_instance = ClipInstance::new(dragging.clip_id)
|
||
.with_timeline_start(drop_time);
|
||
|
||
// For video clips, scale to fill document dimensions
|
||
if dragging.clip_type == DragClipType::Video {
|
||
if let Some((video_width, video_height)) = dragging.dimensions {
|
||
// Calculate scale to fill document
|
||
let scale_x = doc.width / video_width;
|
||
let scale_y = doc.height / video_height;
|
||
|
||
clip_instance.transform.scale_x = scale_x;
|
||
clip_instance.transform.scale_y = scale_y;
|
||
|
||
// Position at (0, 0) to center the scaled video
|
||
// (scaled dimensions = document dimensions, so top-left at origin centers it)
|
||
clip_instance.transform.x = 0.0;
|
||
clip_instance.transform.y = 0.0;
|
||
} else {
|
||
// No dimensions available, use document center
|
||
clip_instance.transform.x = center_x;
|
||
clip_instance.transform.y = center_y;
|
||
}
|
||
} else {
|
||
// Non-video clips: center at document center
|
||
clip_instance.transform.x = center_x;
|
||
clip_instance.transform.y = center_y;
|
||
}
|
||
|
||
(center_x, center_y, clip_instance)
|
||
}; // doc is dropped here
|
||
|
||
// Save instance ID for potential grouping
|
||
let video_instance_id = clip_instance.id;
|
||
|
||
// Create and queue action for video
|
||
let action = lightningbeam_core::actions::AddClipInstanceAction::new(
|
||
layer_id,
|
||
clip_instance,
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
|
||
// If video has linked audio, auto-place it and create group
|
||
if let Some(linked_audio_clip_id) = dragging.linked_audio_clip_id {
|
||
eprintln!("DEBUG: Video has linked audio clip: {}", linked_audio_clip_id);
|
||
|
||
// Find or create sampled audio track where the audio won't overlap
|
||
let audio_layer_id = {
|
||
let doc = shared.action_executor.document();
|
||
let result = find_sampled_audio_track_for_clip(doc, linked_audio_clip_id, drop_time, editing_clip_id.as_ref());
|
||
if let Some(id) = result {
|
||
eprintln!("DEBUG: Found existing audio track without overlap: {}", id);
|
||
} else {
|
||
eprintln!("DEBUG: No suitable audio track found, will create new one");
|
||
}
|
||
result
|
||
}.unwrap_or_else(|| {
|
||
eprintln!("DEBUG: Creating new audio track");
|
||
// Create new sampled audio layer
|
||
let audio_layer = lightningbeam_core::layer::AudioLayer::new_sampled("Audio Track");
|
||
let layer_id = shared.action_executor.document_mut().root.add_child(
|
||
lightningbeam_core::layer::AnyLayer::Audio(audio_layer)
|
||
);
|
||
eprintln!("DEBUG: Created audio layer with ID: {}", layer_id);
|
||
layer_id
|
||
});
|
||
|
||
eprintln!("DEBUG: Using audio layer ID: {}", audio_layer_id);
|
||
|
||
// Create audio clip instance at same timeline position
|
||
let audio_instance = ClipInstance::new(linked_audio_clip_id)
|
||
.with_timeline_start(drop_time);
|
||
let audio_instance_id = audio_instance.id;
|
||
|
||
eprintln!("DEBUG: Created audio instance: {} for clip: {}", audio_instance_id, linked_audio_clip_id);
|
||
|
||
// Queue audio action
|
||
let audio_action = lightningbeam_core::actions::AddClipInstanceAction::new(
|
||
audio_layer_id,
|
||
audio_instance,
|
||
);
|
||
shared.pending_actions.push(Box::new(audio_action));
|
||
eprintln!("DEBUG: Queued audio action, total pending: {}", shared.pending_actions.len());
|
||
|
||
// Create instance group linking video and audio
|
||
let mut group = lightningbeam_core::instance_group::InstanceGroup::new();
|
||
group.add_member(layer_id, video_instance_id);
|
||
group.add_member(audio_layer_id, audio_instance_id);
|
||
shared.action_executor.document_mut().add_instance_group(group);
|
||
eprintln!("DEBUG: Created instance group");
|
||
} else {
|
||
eprintln!("DEBUG: Video has NO linked audio clip!");
|
||
}
|
||
|
||
// Clear drag state
|
||
*shared.dragging_asset = None;
|
||
}
|
||
}
|
||
} else {
|
||
// No existing layer at this position - show "create new layer" indicator
|
||
// and handle drop to create a new layer
|
||
let layer_y = content_rect.min.y + hovered_layer_index as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
||
let highlight_rect = egui::Rect::from_min_size(
|
||
egui::pos2(content_rect.min.x, layer_y),
|
||
egui::vec2(content_rect.width(), LAYER_HEIGHT),
|
||
);
|
||
|
||
// Blue highlight for "will create new layer"
|
||
ui.painter().rect_filled(
|
||
highlight_rect,
|
||
0.0,
|
||
egui::Color32::from_rgba_unmultiplied(100, 150, 255, 40),
|
||
);
|
||
|
||
// Show drop time indicator
|
||
let drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0);
|
||
let drop_x = self.time_to_x(drop_time);
|
||
if drop_x >= 0.0 && drop_x <= content_rect.width() {
|
||
ui.painter().line_segment(
|
||
[
|
||
egui::pos2(content_rect.min.x + drop_x, layer_y),
|
||
egui::pos2(content_rect.min.x + drop_x, layer_y + LAYER_HEIGHT),
|
||
],
|
||
egui::Stroke::new(2.0, egui::Color32::WHITE),
|
||
);
|
||
}
|
||
|
||
// Handle drop on mouse release - create new layer
|
||
if ui.input(|i| i.pointer.any_released()) {
|
||
let drop_time = self.x_to_time(pointer_pos.x - content_rect.min.x).max(0.0);
|
||
|
||
// Create the appropriate layer type
|
||
let layer_name = format!("{} Layer", match dragging.clip_type {
|
||
DragClipType::Vector => "Vector",
|
||
DragClipType::Video => "Video",
|
||
DragClipType::AudioSampled => "Audio",
|
||
DragClipType::AudioMidi => "MIDI",
|
||
DragClipType::Image => "Image",
|
||
DragClipType::Effect => "Effect",
|
||
});
|
||
let new_layer = super::create_layer_for_clip_type(dragging.clip_type, &layer_name);
|
||
let new_layer_id = new_layer.id();
|
||
|
||
// Add the layer to the current editing context
|
||
if let Some(clip_id) = shared.editing_clip_id {
|
||
if let Some(clip) = shared.action_executor.document_mut().vector_clips.get_mut(&clip_id) {
|
||
clip.layers.add_root(new_layer);
|
||
}
|
||
shared.action_executor.document_mut().layer_to_clip_map.insert(new_layer_id, clip_id);
|
||
} else {
|
||
shared.action_executor.document_mut().root.add_child(new_layer);
|
||
}
|
||
|
||
// Now add the clip to the new layer
|
||
if dragging.clip_type == DragClipType::Effect {
|
||
// Handle effect drops
|
||
let effect_def = lightningbeam_core::effect_registry::EffectRegistry::get_by_id(&dragging.clip_id)
|
||
.or_else(|| shared.action_executor.document().get_effect_definition(&dragging.clip_id).cloned());
|
||
|
||
if let Some(def) = effect_def {
|
||
if shared.action_executor.document().get_effect_definition(&def.id).is_none() {
|
||
shared.action_executor.document_mut().add_effect_definition(def.clone());
|
||
}
|
||
|
||
let clip_instance = ClipInstance::new(def.id)
|
||
.with_timeline_start(drop_time)
|
||
.with_timeline_duration(5.0);
|
||
|
||
let action = lightningbeam_core::actions::AddEffectAction::new(
|
||
new_layer_id,
|
||
clip_instance,
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
} else {
|
||
// Handle other clip types
|
||
let clip_instance = ClipInstance::new(dragging.clip_id)
|
||
.with_timeline_start(drop_time);
|
||
|
||
let action = lightningbeam_core::actions::AddClipInstanceAction::new(
|
||
new_layer_id,
|
||
clip_instance,
|
||
);
|
||
shared.pending_actions.push(Box::new(action));
|
||
}
|
||
|
||
// Clear drag state
|
||
*shared.dragging_asset = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Register handler for pending view actions (two-phase dispatch)
|
||
// Priority: Mouse-over (0-99) > Fallback Timeline(1001)
|
||
const TIMELINE_MOUSE_OVER_PRIORITY: u32 = 0;
|
||
const TIMELINE_FALLBACK_PRIORITY: u32 = 1001;
|
||
|
||
let mouse_over = ui.rect_contains_pointer(rect);
|
||
|
||
// Determine our priority for this action
|
||
let our_priority = if mouse_over {
|
||
TIMELINE_MOUSE_OVER_PRIORITY // High priority - mouse is over this pane
|
||
} else {
|
||
TIMELINE_FALLBACK_PRIORITY // Low priority - just a fallback option
|
||
};
|
||
|
||
// Check if we should register as a handler (better priority than current best)
|
||
let should_register = shared.pending_view_action.is_some() &&
|
||
shared.fallback_pane_priority.map_or(true, |p| our_priority < p);
|
||
|
||
if should_register {
|
||
// Update fallback priority tracker
|
||
*shared.fallback_pane_priority = Some(our_priority);
|
||
|
||
// Register as a handler (don't execute yet - that happens after all panes render)
|
||
if let Some(action) = &shared.pending_view_action {
|
||
use crate::menu::MenuAction;
|
||
|
||
// Determine zoom center point (use x-position only for timeline horizontal zoom)
|
||
let center = if mouse_over {
|
||
// Use mouse position for zoom-to-cursor
|
||
let mouse_pos = ui.input(|i| i.pointer.hover_pos()).unwrap_or(rect.center());
|
||
mouse_pos - rect.min
|
||
} else {
|
||
// Use center of viewport for fallback
|
||
rect.size() / 2.0
|
||
};
|
||
|
||
// Only register for actions we can handle
|
||
match action {
|
||
MenuAction::ZoomIn | MenuAction::ZoomOut |
|
||
MenuAction::ActualSize | MenuAction::RecenterView => {
|
||
shared.pending_handlers.push(super::ViewActionHandler {
|
||
priority: our_priority,
|
||
pane_path: _path.clone(),
|
||
zoom_center: center,
|
||
});
|
||
}
|
||
_ => {
|
||
// Not a view action we handle - reset priority so others can try
|
||
*shared.fallback_pane_priority = None;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn name(&self) -> &str {
|
||
"Timeline"
|
||
}
|
||
}
|