Mobile context-menu coverage + z-order

Hook up menu-bar actions that lacked a mobile affordance, and implement z-order.

- Stage/object long-press menu (stage.rs): add Paste (on clip, geometry, and
  empty stage) and Send to Back / Bring to Front on the selection.
- Timeline long-press menu (timeline.rs): the timeline had no mobile context
  menu — add one via manual long-press detection. Clip actions (Split,
  Duplicate, Cut, Copy, Paste, Delete) on a clip; animation actions (New/Blank
  keyframe, Add keyframe at playhead, Duplicate keyframe, Delete frame, Add
  motion/shape tween) on an empty lane. Gate the desktop right-click menu to
  !is_mobile so mobile shows only the long-press menu.
- Implement Send to Back / Bring to Front (were // TODO no-ops): new undoable
  ReorderClipInstancesAction in lightningbeam-core reorders the selected
  instances within their layer's clip_instances Vec (stacking order; last =
  on top; geometry stays flattened underneath). Wired via handle_menu_action.
This commit is contained in:
Skyler Lehmkuhl 2026-07-02 10:07:39 -04:00
parent 4f66ddb515
commit 2cbc35f181
5 changed files with 188 additions and 6 deletions

View File

@ -11,6 +11,7 @@ pub mod add_layer;
pub mod add_shape; pub mod add_shape;
pub mod modify_shape_path; pub mod modify_shape_path;
pub mod move_clip_instances; pub mod move_clip_instances;
pub mod reorder_clip_instances;
pub mod paint_bucket; pub mod paint_bucket;
pub mod remove_effect; pub mod remove_effect;
pub mod set_document_properties; pub mod set_document_properties;
@ -54,6 +55,7 @@ pub use add_layer::AddLayerAction;
pub use add_shape::AddShapeAction; pub use add_shape::AddShapeAction;
pub use modify_shape_path::ModifyGraphAction; pub use modify_shape_path::ModifyGraphAction;
pub use move_clip_instances::MoveClipInstancesAction; pub use move_clip_instances::MoveClipInstancesAction;
pub use reorder_clip_instances::ReorderClipInstancesAction;
pub use paint_bucket::PaintBucketAction; pub use paint_bucket::PaintBucketAction;
pub use remove_effect::RemoveEffectAction; pub use remove_effect::RemoveEffectAction;
pub use set_document_properties::SetDocumentPropertiesAction; pub use set_document_properties::SetDocumentPropertiesAction;

View File

@ -0,0 +1,78 @@
//! Reorder clip instances within a layer's stacking order (Send to Back / Bring to Front).
//!
//! A layer's `clip_instances` Vec order *is* the stacking order — the last element renders on top
//! (hit-testing walks it in reverse). Geometry renders underneath and is unaffected. This action
//! moves the selected instances to the front (end) or back (start) of that Vec.
use crate::action::Action;
use crate::clip::ClipInstance;
use crate::document::Document;
use crate::layer::AnyLayer;
use uuid::Uuid;
pub struct ReorderClipInstancesAction {
layer_id: Uuid,
instance_ids: Vec<Uuid>,
/// `true` = bring to front (top), `false` = send to back (bottom).
to_front: bool,
/// Full instance order captured on `execute`, for `rollback`.
old_order: Option<Vec<Uuid>>,
}
impl ReorderClipInstancesAction {
pub fn new(layer_id: Uuid, instance_ids: Vec<Uuid>, to_front: bool) -> Self {
Self { layer_id, instance_ids, to_front, old_order: None }
}
}
/// The clip-instance stack for a layer, if it has one (Group/Raster/Text don't).
fn clip_instances_mut<'a>(document: &'a mut Document, layer_id: &Uuid) -> Option<&'a mut Vec<ClipInstance>> {
match document.get_layer_mut(layer_id)? {
AnyLayer::Vector(l) => Some(&mut l.clip_instances),
AnyLayer::Audio(l) => Some(&mut l.clip_instances),
AnyLayer::Video(l) => Some(&mut l.clip_instances),
AnyLayer::Effect(l) => Some(&mut l.clip_instances),
_ => None,
}
}
impl Action for ReorderClipInstancesAction {
fn execute(&mut self, document: &mut Document) -> Result<(), String> {
let instances = clip_instances_mut(document, &self.layer_id)
.ok_or_else(|| "Layer has no clip-instance stack".to_string())?;
self.old_order = Some(instances.iter().map(|c| c.id).collect());
let mut selected = Vec::new();
let mut rest = Vec::new();
for ci in instances.drain(..) {
if self.instance_ids.contains(&ci.id) {
selected.push(ci);
} else {
rest.push(ci);
}
}
if self.to_front {
rest.extend(selected); // selected last → rendered on top
*instances = rest;
} else {
selected.extend(rest); // selected first → rendered at the bottom (of the stack)
*instances = selected;
}
Ok(())
}
fn rollback(&mut self, document: &mut Document) -> Result<(), String> {
let Some(order) = self.old_order.clone() else {
return Ok(());
};
let instances = clip_instances_mut(document, &self.layer_id)
.ok_or_else(|| "Layer has no clip-instance stack".to_string())?;
let rank = |id: &Uuid| order.iter().position(|o| o == id).unwrap_or(usize::MAX);
instances.sort_by(|a, b| rank(&a.id).cmp(&rank(&b.id)));
Ok(())
}
fn description(&self) -> String {
if self.to_front { "Bring to Front".to_string() } else { "Send to Back".to_string() }
}
}

View File

@ -2948,6 +2948,39 @@ impl EditorApp {
} }
} }
/// Send the selected clip/group instances to the back or front of their layer's stacking order.
fn reorder_selected_clips(&mut self, to_front: bool) {
use lightningbeam_core::layer::AnyLayer;
let Some(layer_id) = self.active_layer_id else {
return;
};
let ids: Vec<uuid::Uuid> = {
let document = self.action_executor.document();
let Some(layer) = document.get_layer(&layer_id) else {
return;
};
let instances: &[lightningbeam_core::clip::ClipInstance] = match layer {
AnyLayer::Vector(l) => &l.clip_instances,
AnyLayer::Audio(l) => &l.clip_instances,
AnyLayer::Video(l) => &l.clip_instances,
AnyLayer::Effect(l) => &l.clip_instances,
_ => &[],
};
instances
.iter()
.filter(|ci| self.selection.contains_clip_instance(&ci.id))
.map(|ci| ci.id)
.collect()
};
if ids.is_empty() {
return;
}
let action = lightningbeam_core::actions::ReorderClipInstancesAction::new(layer_id, ids, to_front);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Failed to reorder clip instances: {e}");
}
}
/// Duplicate the selected clip instances on the active layer. /// Duplicate the selected clip instances on the active layer.
/// Each duplicate is placed immediately after the original clip. /// Each duplicate is placed immediately after the original clip.
fn duplicate_selected_clips(&mut self) { fn duplicate_selected_clips(&mut self) {
@ -3588,12 +3621,10 @@ impl EditorApp {
} }
} }
MenuAction::SendToBack => { MenuAction::SendToBack => {
println!("Menu: Send to Back"); self.reorder_selected_clips(false);
// TODO: Implement send to back
} }
MenuAction::BringToFront => { MenuAction::BringToFront => {
println!("Menu: Bring to Front"); self.reorder_selected_clips(true);
// TODO: Implement bring to front
} }
MenuAction::SplitClip => { MenuAction::SplitClip => {
self.split_clips_at_playhead(); self.split_clips_at_playhead();

View File

@ -11537,14 +11537,23 @@ impl StagePane {
// A clip instance is already a clip — no "convert" here. // A clip instance is already a clip — no "convert" here.
items.push(("Cut".into(), MenuAction::Cut)); items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy)); items.push(("Copy".into(), MenuAction::Copy));
items.push(("Paste".into(), MenuAction::Paste));
items.push(("Duplicate".into(), MenuAction::DuplicateClip)); items.push(("Duplicate".into(), MenuAction::DuplicateClip));
items.push(("Delete".into(), MenuAction::Delete)); items.push(("Delete".into(), MenuAction::Delete));
items.push(("Send to back".into(), MenuAction::SendToBack));
items.push(("Bring to front".into(), MenuAction::BringToFront));
} else if shared.selection.has_geometry_selection() { } else if shared.selection.has_geometry_selection() {
// Geometry can be gathered up into a movie clip. // Geometry can be gathered up into a movie clip.
items.push(("Cut".into(), MenuAction::Cut)); items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy)); items.push(("Copy".into(), MenuAction::Copy));
items.push(("Paste".into(), MenuAction::Paste));
items.push(("Convert to movie clip".into(), MenuAction::ConvertToMovieClip)); items.push(("Convert to movie clip".into(), MenuAction::ConvertToMovieClip));
items.push(("Delete".into(), MenuAction::Delete)); items.push(("Delete".into(), MenuAction::Delete));
items.push(("Send to back".into(), MenuAction::SendToBack));
items.push(("Bring to front".into(), MenuAction::BringToFront));
} else {
// Nothing selected — offer paste onto the stage.
items.push(("Paste".into(), MenuAction::Paste));
} }
if !items.is_empty() { if !items.is_empty() {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {

View File

@ -268,6 +268,9 @@ pub struct TimelinePane {
/// Is the user panning the timeline? /// Is the user panning the timeline?
is_panning: bool, is_panning: bool,
last_pan_pos: Option<egui::Pos2>, last_pan_pos: Option<egui::Pos2>,
/// Manual long-press tracking for the mobile context menu: press start time + position.
lp_time: Option<f64>,
lp_pos: egui::Pos2,
/// Clip drag state (None if not dragging) /// Clip drag state (None if not dragging)
clip_drag_state: Option<ClipDragType>, clip_drag_state: Option<ClipDragType>,
@ -788,6 +791,8 @@ impl TimelinePane {
is_scrubbing: false, is_scrubbing: false,
is_panning: false, is_panning: false,
last_pan_pos: None, last_pan_pos: None,
lp_time: None,
lp_pos: egui::Pos2::ZERO,
clip_drag_state: None, clip_drag_state: None,
drag_offset: 0.0, drag_offset: 0.0,
drag_anchor_start: 0.0, drag_anchor_start: 0.0,
@ -5837,9 +5842,10 @@ impl PaneRenderer for TimelinePane {
} }
ui.set_clip_rect(original_clip_rect); ui.set_clip_rect(original_clip_rect);
// Context menu: detect right-click on clips or empty timeline space // Context menu: detect right-click on clips or empty timeline space (desktop only — mobile
// uses the long-press menu below).
let mut just_opened_menu = false; let mut just_opened_menu = false;
let secondary_clicked = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary)); let secondary_clicked = !shared.is_mobile && ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Secondary));
if secondary_clicked { if secondary_clicked {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) { if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
if content_rect.contains(pos) { if content_rect.contains(pos) {
@ -5859,6 +5865,62 @@ impl PaneRenderer for TimelinePane {
} }
} }
// Mobile: a manual long-press (hold in place ~0.4s) builds a persistent context menu rendered
// by the shell — clip actions on a clip, animation (keyframe/tween) actions on an empty lane.
let mut long_press: Option<egui::Pos2> = None;
if shared.is_mobile {
let now = ui.input(|i| i.time);
let down = ui.input(|i| i.pointer.any_down());
let ptr = ui.input(|i| i.pointer.latest_pos());
if ui.input(|i| i.pointer.primary_pressed()) {
match ptr {
Some(p) if content_rect.contains(p) => {
self.lp_time = Some(now);
self.lp_pos = p;
}
_ => self.lp_time = None,
}
}
if !down {
self.lp_time = None;
}
if let Some(t0) = self.lp_time {
let moved = ptr.map_or(0.0, |p| (p - self.lp_pos).length());
if moved > 8.0 {
self.lp_time = None;
} else if now - t0 >= 0.4 {
self.lp_time = None;
long_press = Some(self.lp_pos);
}
}
}
if let Some(pos) = long_press {
use crate::menu::MenuAction;
let mut items: Vec<(String, MenuAction)> = Vec::new();
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) {
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());
items.push(("Split clip".into(), MenuAction::SplitClip));
items.push(("Duplicate clip".into(), MenuAction::DuplicateClip));
items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy));
items.push(("Paste".into(), MenuAction::Paste));
items.push(("Delete".into(), MenuAction::Delete));
} else {
items.push(("New keyframe".into(), MenuAction::NewKeyframe));
items.push(("New blank keyframe".into(), MenuAction::NewBlankKeyframe));
items.push(("Add keyframe at playhead".into(), MenuAction::AddKeyframeAtPlayhead));
items.push(("Duplicate keyframe".into(), MenuAction::DuplicateKeyframe));
items.push(("Delete frame".into(), MenuAction::DeleteFrame));
items.push(("Add motion tween".into(), MenuAction::AddMotionTween));
items.push(("Add shape tween".into(), MenuAction::AddShapeTween));
items.push(("Paste".into(), MenuAction::Paste));
}
*shared.mobile_context_menu = Some(crate::panes::MobileContextMenu { pos, items });
}
// Render context menu // Render context menu
if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip { if let Some((ctx_clip_id, menu_pos)) = self.context_menu_clip {
let has_clip = ctx_clip_id.is_some(); let has_clip = ctx_clip_id.is_some();