Compare commits
2 Commits
c60eef0c5a
...
8d8f94a547
| Author | SHA1 | Date |
|---|---|---|
|
|
8d8f94a547 | |
|
|
516960062a |
|
|
@ -31,6 +31,7 @@ pub mod convert_to_movie_clip;
|
||||||
pub mod region_split;
|
pub mod region_split;
|
||||||
pub mod toggle_group_expansion;
|
pub mod toggle_group_expansion;
|
||||||
pub mod group_layers;
|
pub mod group_layers;
|
||||||
|
pub mod move_layer;
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -60,3 +61,4 @@ pub use convert_to_movie_clip::ConvertToMovieClipAction;
|
||||||
pub use region_split::RegionSplitAction;
|
pub use region_split::RegionSplitAction;
|
||||||
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
pub use toggle_group_expansion::ToggleGroupExpansionAction;
|
||||||
pub use group_layers::GroupLayersAction;
|
pub use group_layers::GroupLayersAction;
|
||||||
|
pub use move_layer::MoveLayerAction;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,137 @@
|
||||||
|
use crate::action::Action;
|
||||||
|
use crate::document::Document;
|
||||||
|
use crate::layer::AnyLayer;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Action that moves one or more layers to a new position, possibly changing their parent group.
|
||||||
|
/// All layers are inserted contiguously into the same target parent.
|
||||||
|
/// Handles batch moves atomically: removes all, then inserts all, so indices stay consistent.
|
||||||
|
pub struct MoveLayerAction {
|
||||||
|
/// (layer_id, old_parent_id) for each layer to move, in visual order (top to bottom)
|
||||||
|
layers: Vec<(Uuid, Option<Uuid>)>,
|
||||||
|
new_parent_id: Option<Uuid>,
|
||||||
|
/// Insertion index in the new parent's children vec AFTER all dragged layers have been removed
|
||||||
|
new_base_index: usize,
|
||||||
|
/// Stored during execute for rollback: (layer, old_parent_id, old_index_in_parent)
|
||||||
|
removed: Vec<(AnyLayer, Option<Uuid>, usize)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MoveLayerAction {
|
||||||
|
pub fn new(
|
||||||
|
layers: Vec<(Uuid, Option<Uuid>)>,
|
||||||
|
new_parent_id: Option<Uuid>,
|
||||||
|
new_base_index: usize,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
layers,
|
||||||
|
new_parent_id,
|
||||||
|
new_base_index,
|
||||||
|
removed: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_parent_children(
|
||||||
|
document: &mut Document,
|
||||||
|
parent_id: Option<Uuid>,
|
||||||
|
) -> Result<&mut Vec<AnyLayer>, String> {
|
||||||
|
match parent_id {
|
||||||
|
None => Ok(&mut document.root.children),
|
||||||
|
Some(id) => {
|
||||||
|
let layer = document
|
||||||
|
.root
|
||||||
|
.get_child_mut(&id)
|
||||||
|
.ok_or_else(|| format!("Parent group {} not found", id))?;
|
||||||
|
match layer {
|
||||||
|
AnyLayer::Group(g) => Ok(&mut g.children),
|
||||||
|
_ => Err(format!("Layer {} is not a group", id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for MoveLayerAction {
|
||||||
|
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
|
self.removed.clear();
|
||||||
|
|
||||||
|
// Phase 1: Remove all layers from their old parents.
|
||||||
|
// Group removals by parent, then remove back-to-front within each parent.
|
||||||
|
// Collect (layer_id, old_parent_id) with their current index.
|
||||||
|
let mut removals: Vec<(Uuid, Option<Uuid>, usize)> = Vec::new();
|
||||||
|
for (layer_id, old_parent_id) in &self.layers {
|
||||||
|
let children = get_parent_children(document, *old_parent_id)?;
|
||||||
|
let idx = children.iter().position(|l| l.id() == *layer_id)
|
||||||
|
.ok_or_else(|| format!("Layer {} not found in parent", layer_id))?;
|
||||||
|
removals.push((*layer_id, *old_parent_id, idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by (parent, index) descending so we remove back-to-front
|
||||||
|
removals.sort_by(|a, b| {
|
||||||
|
a.1.cmp(&b.1).then(b.2.cmp(&a.2))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut removed_layers: Vec<(Uuid, AnyLayer, Option<Uuid>, usize)> = Vec::new();
|
||||||
|
for (layer_id, old_parent_id, idx) in &removals {
|
||||||
|
let children = get_parent_children(document, *old_parent_id)?;
|
||||||
|
let layer = children.remove(*idx);
|
||||||
|
removed_layers.push((*layer_id, layer, *old_parent_id, *idx));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Insert all at new parent, in visual order (self.layers order).
|
||||||
|
// self.new_base_index is the index in the post-removal children vec.
|
||||||
|
let new_children = get_parent_children(document, self.new_parent_id)?;
|
||||||
|
let base = self.new_base_index.min(new_children.len());
|
||||||
|
|
||||||
|
// Insert in forward visual order, all at `base`. Each insert pushes the previous
|
||||||
|
// one to a higher children index. Since the timeline displays children in reverse,
|
||||||
|
// a higher children index = visually higher. So the first visual layer (layers[0])
|
||||||
|
// ends up at the highest children index = visually topmost. Correct.
|
||||||
|
for (layer_id, _) in self.layers.iter() {
|
||||||
|
// Find this layer in removed_layers
|
||||||
|
let pos = removed_layers.iter().position(|(id, _, _, _)| id == layer_id)
|
||||||
|
.ok_or_else(|| format!("Layer {} missing from removed set", layer_id))?;
|
||||||
|
let (_, layer, old_parent_id, old_idx) = removed_layers.remove(pos);
|
||||||
|
self.removed.push((layer.clone(), old_parent_id, old_idx));
|
||||||
|
|
||||||
|
let new_children = get_parent_children(document, self.new_parent_id)?;
|
||||||
|
let insert_at = base.min(new_children.len());
|
||||||
|
new_children.insert(insert_at, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
|
||||||
|
if self.removed.is_empty() {
|
||||||
|
return Err("Cannot rollback: action was not executed".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 1: Remove all layers from new parent (back-to-front by insertion order).
|
||||||
|
for (layer_id, _) in self.layers.iter().rev() {
|
||||||
|
let new_children = get_parent_children(document, self.new_parent_id)?;
|
||||||
|
let pos = new_children.iter().position(|l| l.id() == *layer_id)
|
||||||
|
.ok_or_else(|| format!("Layer {} not found in new parent for rollback", layer_id))?;
|
||||||
|
new_children.remove(pos);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Re-insert at old positions, sorted by (parent, index) ascending.
|
||||||
|
let mut restore: Vec<(AnyLayer, Option<Uuid>, usize)> = self.removed.drain(..).collect();
|
||||||
|
restore.sort_by(|a, b| a.1.cmp(&b.1).then(a.2.cmp(&b.2)));
|
||||||
|
|
||||||
|
for (layer, old_parent_id, old_idx) in restore {
|
||||||
|
let children = get_parent_children(document, old_parent_id)?;
|
||||||
|
let idx = old_idx.min(children.len());
|
||||||
|
children.insert(idx, layer);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
if self.layers.len() == 1 {
|
||||||
|
"Move layer".to_string()
|
||||||
|
} else {
|
||||||
|
format!("Move {} layers", self.layers.len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6016,7 +6016,7 @@ fn render_pane(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Icon
|
// Icon
|
||||||
if let Some(icon) = ctx.icon_cache.get_or_load(tab_type, ui.ctx()) {
|
if let Some(icon) = ctx.shared.icon_cache.get_or_load(tab_type, ui.ctx()) {
|
||||||
let icon_texture_id = icon.id();
|
let icon_texture_id = icon.id();
|
||||||
ui.painter().image(
|
ui.painter().image(
|
||||||
icon_texture_id,
|
icon_texture_id,
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,20 @@ enum RecordingType {
|
||||||
Webcam,
|
Webcam,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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 {
|
pub struct TimelinePane {
|
||||||
/// Horizontal zoom level (pixels per second)
|
/// Horizontal zoom level (pixels per second)
|
||||||
pixels_per_second: f32,
|
pixels_per_second: f32,
|
||||||
|
|
@ -189,6 +203,12 @@ pub struct TimelinePane {
|
||||||
/// Cached egui textures for video thumbnail strip rendering.
|
/// Cached egui textures for video thumbnail strip rendering.
|
||||||
/// Key: (clip_id, thumbnail_timestamp_millis) → TextureHandle
|
/// Key: (clip_id, thumbnail_timestamp_millis) → TextureHandle
|
||||||
video_thumbnail_textures: std::collections::HashMap<(uuid::Uuid, i64), egui::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>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a clip type can be dropped on a layer type
|
/// Check if a clip type can be dropped on a layer type
|
||||||
|
|
@ -307,6 +327,43 @@ fn flatten_layer<'a>(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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,
|
/// 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).
|
/// enforcing the sibling constraint (all selected layers must share the same parent).
|
||||||
fn shift_toggle_layer(
|
fn shift_toggle_layer(
|
||||||
|
|
@ -428,6 +485,8 @@ impl TimelinePane {
|
||||||
time_display_format: TimeDisplayFormat::Seconds,
|
time_display_format: TimeDisplayFormat::Seconds,
|
||||||
waveform_upload_progress: std::collections::HashMap::new(),
|
waveform_upload_progress: std::collections::HashMap::new(),
|
||||||
video_thumbnail_textures: std::collections::HashMap::new(),
|
video_thumbnail_textures: std::collections::HashMap::new(),
|
||||||
|
layer_drag: None,
|
||||||
|
header_mousedown_pos: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1160,11 +1219,27 @@ impl TimelinePane {
|
||||||
let secondary_text_color = egui::Color32::from_gray(150);
|
let secondary_text_color = egui::Color32::from_gray(150);
|
||||||
|
|
||||||
// Build virtual row list (accounts for group expansion)
|
// Build virtual row list (accounts for group expansion)
|
||||||
let rows = build_timeline_rows(context_layers);
|
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()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Draw layer headers from virtual row list
|
// Draw layer headers from virtual row list
|
||||||
for (i, row) in rows.iter().enumerate() {
|
for (filtered_i, row) in rows.iter().enumerate() {
|
||||||
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
// Compute Y with gap offset: rows at or after the gap shift down by drag_count * LAYER_HEIGHT
|
||||||
|
let visual_i = match gap_row_index {
|
||||||
|
Some(gap) if filtered_i >= gap => filtered_i + drag_count,
|
||||||
|
_ => filtered_i,
|
||||||
|
};
|
||||||
|
let y = rect.min.y + visual_i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
||||||
|
|
||||||
// Skip if layer is outside visible area
|
// Skip if layer is outside visible area
|
||||||
if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y {
|
if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y {
|
||||||
|
|
@ -1550,6 +1625,100 @@ impl TimelinePane {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) = 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)),
|
||||||
|
};
|
||||||
|
(layer.layer().name.clone(), lt, tc)
|
||||||
|
}
|
||||||
|
TimelineRow::CollapsedGroup { group, .. } => {
|
||||||
|
(group.layer.name.clone(), "Group", egui::Color32::from_rgb(0, 180, 180))
|
||||||
|
}
|
||||||
|
TimelineRow::GroupChild { child, .. } => {
|
||||||
|
let (lt, tc) = match child {
|
||||||
|
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)),
|
||||||
|
};
|
||||||
|
(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, egui::Color32::from_gray(20)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Right border for header column
|
// Right border for header column
|
||||||
ui.painter().line_segment(
|
ui.painter().line_segment(
|
||||||
[
|
[
|
||||||
|
|
@ -1602,11 +1771,64 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build virtual row list (accounts for group expansion)
|
// Build virtual row list (accounts for group expansion)
|
||||||
let rows = build_timeline_rows(context_layers);
|
let all_rows = build_timeline_rows(context_layers);
|
||||||
|
|
||||||
// Draw layer rows from virtual row list
|
// When dragging layers, compute remapped Y positions:
|
||||||
for (i, row) in rows.iter().enumerate() {
|
// - Dragged rows render at the gap position
|
||||||
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
|
// - 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);
|
||||||
|
|
||||||
|
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, shifted around gap
|
||||||
|
let visual = match gap_row_index_content {
|
||||||
|
Some(gap) if filtered_i >= gap => filtered_i + drag_count_content,
|
||||||
|
_ => filtered_i,
|
||||||
|
};
|
||||||
|
positions.push(rect.min.y + visual as f32 * LAYER_HEIGHT - 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
|
// Skip if layer is outside visible area
|
||||||
if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y {
|
if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y {
|
||||||
|
|
@ -1618,6 +1840,11 @@ impl TimelinePane {
|
||||||
egui::vec2(rect.width(), LAYER_HEIGHT),
|
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();
|
let row_layer_id = row.layer_id();
|
||||||
|
|
||||||
// Active vs inactive background colors
|
// Active vs inactive background colors
|
||||||
|
|
@ -2793,7 +3020,6 @@ impl TimelinePane {
|
||||||
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
context_layers: &[&lightningbeam_core::layer::AnyLayer],
|
||||||
editing_clip_id: Option<&uuid::Uuid>,
|
editing_clip_id: Option<&uuid::Uuid>,
|
||||||
) {
|
) {
|
||||||
// Don't allocate the header area for input - let widgets handle it directly
|
|
||||||
// Only allocate content area (ruler + layers) with click and drag
|
// Only allocate content area (ruler + layers) with click and drag
|
||||||
let content_response = ui.allocate_rect(
|
let content_response = ui.allocate_rect(
|
||||||
egui::Rect::from_min_size(
|
egui::Rect::from_min_size(
|
||||||
|
|
@ -2913,33 +3139,216 @@ impl TimelinePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle layer header selection (only if no control widget was clicked)
|
// Layer header drag-to-reorder (manual pointer tracking, no allocate_rect)
|
||||||
// Check for clicks in header area using direct input query
|
let pointer_pos = ui.input(|i| i.pointer.hover_pos());
|
||||||
let header_clicked = ui.input(|i| {
|
let primary_down = ui.input(|i| i.pointer.button_down(egui::PointerButton::Primary));
|
||||||
i.pointer.button_clicked(egui::PointerButton::Primary) &&
|
let primary_pressed = ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary));
|
||||||
i.pointer.interact_pos().map_or(false, |pos| header_rect.contains(pos))
|
let primary_released = ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary));
|
||||||
});
|
|
||||||
|
|
||||||
if header_clicked && !alt_held && !clicked_clip_instance && !self.layer_control_clicked {
|
// Handle layer header selection on mousedown (immediate, not on release)
|
||||||
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
|
if primary_pressed && !alt_held && !self.layer_control_clicked {
|
||||||
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
if let Some(pos) = pointer_pos {
|
||||||
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
if header_rect.contains(pos) {
|
||||||
|
let relative_y = pos.y - header_rect.min.y + self.viewport_scroll_y;
|
||||||
// Get the layer at this index (using virtual rows for group support)
|
let clicked_layer_index = (relative_y / LAYER_HEIGHT) as usize;
|
||||||
let header_rows = build_timeline_rows(context_layers);
|
let header_rows = build_timeline_rows(context_layers);
|
||||||
if clicked_layer_index < header_rows.len() {
|
if clicked_layer_index < header_rows.len() {
|
||||||
let layer_id = header_rows[clicked_layer_index].layer_id();
|
let layer_id = header_rows[clicked_layer_index].layer_id();
|
||||||
let clicked_parent = header_rows[clicked_layer_index].parent_id();
|
let clicked_parent = header_rows[clicked_layer_index].parent_id();
|
||||||
*active_layer_id = Some(layer_id);
|
*active_layer_id = Some(layer_id);
|
||||||
if shift_held {
|
if shift_held {
|
||||||
shift_toggle_layer(focus, layer_id, clicked_parent, &header_rows);
|
shift_toggle_layer(focus, layer_id, clicked_parent, &header_rows);
|
||||||
} else {
|
} else {
|
||||||
*focus = lightningbeam_core::selection::FocusSelection::Layers(vec![layer_id]);
|
// 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)
|
// Cache mouse position on mousedown (before any dragging)
|
||||||
if response.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) {
|
if response.hovered() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) {
|
||||||
if let Some(pos) = response.hover_pos() {
|
if let Some(pos) = response.hover_pos() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue