Compare commits

..

2 Commits

Author SHA1 Message Date
Skyler Lehmkuhl 8d8f94a547 Make layer dragging graphics nicer 2026-03-01 12:09:41 -05:00
Skyler Lehmkuhl 516960062a Drag layers to reorder 2026-03-01 11:54:41 -05:00
4 changed files with 578 additions and 30 deletions

View File

@ -31,6 +31,7 @@ pub mod convert_to_movie_clip;
pub mod region_split;
pub mod toggle_group_expansion;
pub mod group_layers;
pub mod move_layer;
pub use add_clip_instance::AddClipInstanceAction;
pub use add_effect::AddEffectAction;
@ -60,3 +61,4 @@ pub use convert_to_movie_clip::ConvertToMovieClipAction;
pub use region_split::RegionSplitAction;
pub use toggle_group_expansion::ToggleGroupExpansionAction;
pub use group_layers::GroupLayersAction;
pub use move_layer::MoveLayerAction;

View File

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

View File

@ -6016,7 +6016,7 @@ fn render_pane(
}
// 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();
ui.painter().image(
icon_texture_id,

View File

@ -145,6 +145,20 @@ enum RecordingType {
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 {
/// Horizontal zoom level (pixels per second)
pixels_per_second: f32,
@ -189,6 +203,12 @@ pub struct TimelinePane {
/// 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>,
}
/// 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,
/// enforcing the sibling constraint (all selected layers must share the same parent).
fn shift_toggle_layer(
@ -428,6 +485,8 @@ impl TimelinePane {
time_display_format: TimeDisplayFormat::Seconds,
waveform_upload_progress: 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);
// 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
for (i, row) in rows.iter().enumerate() {
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
for (filtered_i, row) in rows.iter().enumerate() {
// 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
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
ui.painter().line_segment(
[
@ -1602,11 +1771,64 @@ impl TimelinePane {
}
// 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
for (i, row) in rows.iter().enumerate() {
let y = rect.min.y + i as f32 * LAYER_HEIGHT - self.viewport_scroll_y;
// 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);
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
if y + LAYER_HEIGHT < rect.min.y || y > rect.max.y {
@ -1618,6 +1840,11 @@ impl TimelinePane {
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
@ -2793,7 +3020,6 @@ impl TimelinePane {
context_layers: &[&lightningbeam_core::layer::AnyLayer],
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
let content_response = ui.allocate_rect(
egui::Rect::from_min_size(
@ -2913,19 +3139,18 @@ impl TimelinePane {
}
}
// Handle layer header selection (only if no control widget was clicked)
// Check for clicks in header area using direct input query
let header_clicked = ui.input(|i| {
i.pointer.button_clicked(egui::PointerButton::Primary) &&
i.pointer.interact_pos().map_or(false, |pos| header_rect.contains(pos))
});
// 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));
if header_clicked && !alt_held && !clicked_clip_instance && !self.layer_control_clicked {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
// 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;
// Get the layer at this index (using virtual rows for group support)
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();
@ -2934,10 +3159,194 @@ impl TimelinePane {
if shift_held {
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)