P5 mobile gestures + long-press context menus

Gestures (Stage + Timeline):
- Pinch-zoom via zoom_delta() (touch pinch + Ctrl+wheel), unified with the raw-wheel
  path so Ctrl+wheel zooms exactly once; plain wheel zoom + trackpad pan preserved.
- Double-tap empty → zoom-to-fit (Stage: artboard via zoom_to_fit; Timeline: new
  fit_to_project over the full duration).
- Double-tap-drag on empty → transient marquee regardless of the active tool.

Long-press context menu: a shared SharedPaneState.mobile_context_menu that panes
populate with (label, MenuAction) items on secondary_clicked(); the mobile shell
renders one persistent popup (styled like the timeline menu) and dispatches via
pending_menu_actions. Stage offers Cut/Copy/Duplicate/Delete for clip instances and
Cut/Copy/Convert-to-movie-clip/Delete for geometry.

Fixes surfaced along the way:
- Geometry delete now frees selected fills (free_fill) and GCs isolated vertices
  (new VectorGraph::gc_isolated_vertices) — no more orphaned fills / phantom snap
  points; applies to the Delete key too.
- clipboard_delete_selection clears focus so deleting dismisses the mobile inspector.
- Mobile inspector: appears on pointer release (not press) so drags aren't
  interrupted; reflows only when the tapped point is actually behind the sheet; taps
  outside the sheet dismiss it; rounded-corner border no longer detaches.
This commit is contained in:
Skyler Lehmkuhl 2026-07-02 00:11:43 -04:00
parent 4b2802076b
commit 01b20165cc
8 changed files with 474 additions and 122 deletions

View File

@ -351,6 +351,29 @@ impl VectorGraph {
} }
} }
/// Free any vertex no longer referenced by a non-deleted edge (e.g. after deleting a shape's
/// edges), so stale vertices don't linger as snap targets.
pub fn gc_isolated_vertices(&mut self) {
let mut referenced = vec![false; self.vertices.len()];
for e in &self.edges {
if e.deleted {
continue;
}
for v in e.vertices.iter() {
if !v.is_none() {
referenced[v.idx()] = true;
}
}
}
let to_free: Vec<VertexId> = (0..self.vertices.len())
.filter(|&i| !self.vertices[i].deleted && !referenced[i])
.map(|i| VertexId(i as u32))
.collect();
for vid in to_free {
self.free_vertex(vid);
}
}
// ------------------------------------------------------------------- // -------------------------------------------------------------------
// Fill / hit-test queries // Fill / hit-test queries
// ------------------------------------------------------------------- // -------------------------------------------------------------------

View File

@ -959,6 +959,8 @@ struct EditorApp {
mobile_ui_override: Option<bool>, mobile_ui_override: Option<bool>,
/// Persistent state for the mobile shell (active surface, ribbon tier). /// Persistent state for the mobile shell (active surface, ribbon tier).
mobile_state: mobile::MobileState, mobile_state: mobile::MobileState,
/// Active mobile long-press context menu (set by a pane, rendered by the shell).
mobile_context_menu: Option<panes::MobileContextMenu>,
icon_cache: IconCache, icon_cache: IconCache,
tool_icon_cache: ToolIconCache, tool_icon_cache: ToolIconCache,
focus_icon_cache: FocusIconCache, // Focus card icons (start screen) focus_icon_cache: FocusIconCache, // Focus card icons (start screen)
@ -1320,6 +1322,7 @@ impl EditorApp {
mobile_ui: mobile::is_mobile_env(), mobile_ui: mobile::is_mobile_env(),
mobile_ui_override: None, mobile_ui_override: None,
mobile_state: mobile::MobileState::default(), mobile_state: mobile::MobileState::default(),
mobile_context_menu: None,
icon_cache: IconCache::new(), icon_cache: IconCache::new(),
tool_icon_cache: ToolIconCache::new(), tool_icon_cache: ToolIconCache::new(),
focus_icon_cache: FocusIconCache::new(), focus_icon_cache: FocusIconCache::new(),
@ -2681,45 +2684,56 @@ impl EditorApp {
} }
self.selection.clear_clip_instances(); self.selection.clear_clip_instances();
self.focus = lightningbeam_core::selection::FocusSelection::None;
} else if self.selection.has_geometry_selection() { } else if self.selection.has_geometry_selection() {
let active_layer_id = match self.active_layer_id { let active_layer_id = match self.active_layer_id {
Some(id) => id, Some(id) => id,
None => return, None => return,
}; };
// Delete the selected edges (region/marquee/click all populate the same sets). // Delete the selected geometry (region/marquee/click all populate the same sets).
// Selecting a fill also selects its boundary edges, so removing edges alone would leave
// the fill's face orphaned — free the fills too so the whole shape is removed.
let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> = let edge_ids: Vec<lightningbeam_core::vector_graph::EdgeId> =
self.selection.selected_edges().iter().copied().collect(); self.selection.selected_edges().iter().copied().collect();
let fill_ids: Vec<lightningbeam_core::vector_graph::FillId> =
self.selection.selected_fills().iter().copied().collect();
if !edge_ids.is_empty() { if !edge_ids.is_empty() || !fill_ids.is_empty() {
let document = self.action_executor.document(); let document = self.action_executor.document();
if let Some(layer) = document.get_layer(&active_layer_id) { if let Some(lightningbeam_core::layer::AnyLayer::Vector(vector_layer)) = document.get_layer(&active_layer_id) {
if let lightningbeam_core::layer::AnyLayer::Vector(vector_layer) = layer { if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) {
if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) { let mut graph_after = graph_before.clone();
let mut graph_after = graph_before.clone(); // Free fills first so their boundary edges become unreferenced and are GC'd.
for edge_id in &edge_ids { for fill_id in &fill_ids {
if !graph_after.edge(*edge_id).deleted { if !graph_after.fill(*fill_id).deleted {
graph_after.remove_edge(*edge_id); graph_after.free_fill(*fill_id);
}
} }
}
let action = lightningbeam_core::actions::ModifyGraphAction::new( for edge_id in &edge_ids {
active_layer_id, if !graph_after.edge(*edge_id).deleted {
self.playback_time, graph_after.remove_edge(*edge_id);
graph_before.clone(),
graph_after,
"Delete selected edges",
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Delete DCEL edges failed: {}", e);
} }
} }
graph_after.gc_isolated_vertices();
let action = lightningbeam_core::actions::ModifyGraphAction::new(
active_layer_id,
self.playback_time,
graph_before.clone(),
graph_after,
"Delete",
);
if let Err(e) = self.action_executor.execute(Box::new(action)) {
eprintln!("Delete geometry failed: {}", e);
}
} }
} }
} }
self.selection.clear_geometry_selection(); self.selection.clear_geometry_selection();
self.focus = lightningbeam_core::selection::FocusSelection::None;
} }
} }
@ -7182,6 +7196,7 @@ impl EditorApp {
rc: RenderContext { rc: RenderContext {
shared: panes::SharedPaneState { shared: panes::SharedPaneState {
is_mobile, is_mobile,
mobile_context_menu: &mut self.mobile_context_menu,
container_path: self.current_file_path.clone(), container_path: self.current_file_path.clone(),
onion: { onion: {
// Onion skinning is disabled during playback. // Onion skinning is disabled during playback.

View File

@ -24,6 +24,7 @@ pub fn is_active(shared: &SharedPaneState) -> bool {
/// The stack slot (see `super::STACK`) where the current selection lives, so we can tell if the /// The stack slot (see `super::STACK`) where the current selection lives, so we can tell if the
/// sheet would cover it. Geometry/selection lives on the Stage; clips/layers on the Timeline; etc. /// sheet would cover it. Geometry/selection lives on the Stage; clips/layers on the Timeline; etc.
#[allow(dead_code)] // selection→pane mapping; kept for reflow/jump heuristics
pub fn target_slot(shared: &SharedPaneState) -> usize { pub fn target_slot(shared: &SharedPaneState) -> usize {
match &*shared.focus { match &*shared.focus {
FocusSelection::Notes { .. } => 4, // PianoRoll FocusSelection::Notes { .. } => 4, // PianoRoll
@ -99,13 +100,11 @@ pub fn render(
state: &mut MobileState, state: &mut MobileState,
pal: &Palette, pal: &Palette,
) { ) {
// Sheet background with rounded top. // Sheet background with rounded top + a border stroke that follows the rounded corners (a plain
ui.painter().rect_filled( // hline would overrun the rounded corners and detach from them).
rect, let radius = egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 };
egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 }, ui.painter().rect_filled(rect, radius, pal.surface_alt);
pal.surface_alt, ui.painter().rect_stroke(rect, radius, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
);
ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line));
// Grab handle — drag to resize the sheet. // Grab handle — drag to resize the sheet.
let grab = egui::Rect::from_min_max( let grab = egui::Rect::from_min_max(

View File

@ -191,6 +191,12 @@ pub struct MobileState {
pub show_instruments: bool, pub show_instruments: bool,
/// Inspector sheet height as a fraction of the region above the transport. /// Inspector sheet height as a fraction of the region above the transport.
pub inspector_frac: f32, pub inspector_frac: f32,
/// Whether the inspector sheet is currently shown. Gated to appear on pointer *release* (a tap),
/// not on press, so press+drag interactions aren't interrupted by the sheet popping up.
pub inspector_visible: bool,
/// Screen-y of the tap that opened the inspector — used to decide whether the tapped thing would
/// be hidden by the sheet (only then do we reflow the stack).
pub inspector_anchor_y: f32,
/// Whether the omnibutton radial tool menu is open. /// Whether the omnibutton radial tool menu is open.
pub omni_open: bool, pub omni_open: bool,
/// Whether the omnibutton "more" grid (all tools) is open. /// Whether the omnibutton "more" grid (all tools) is open.
@ -217,6 +223,8 @@ impl Default for MobileState {
weights: [1.0, 1.0, 1.0], weights: [1.0, 1.0, 1.0],
show_instruments: false, show_instruments: false,
inspector_frac: 0.45, inspector_frac: 0.45,
inspector_visible: false,
inspector_anchor_y: 0.0,
omni_open: false, omni_open: false,
omni_grid_open: false, omni_grid_open: false,
omni_create_open: false, omni_create_open: false,
@ -255,22 +263,55 @@ pub fn render_mobile_shell(
egui::pos2(available_rect.left(), topbar_rect.bottom()), egui::pos2(available_rect.left(), topbar_rect.bottom()),
egui::pos2(available_rect.right(), transport_rect.top()), egui::pos2(available_rect.right(), transport_rect.top()),
); );
// When the inspector is up, the sheet overlays the lower part of the stack. If the selected // The inspector sheet appears on pointer *release* (a tap), not on press — so press+drag on the
// pane would be *covered* by the sheet, reflow (shrink the stack above the sheet) so it stays // canvas isn't interrupted by the sheet popping up. Track visibility: hide when nothing's
// visible; otherwise leave the stack full-height and just overlay. Restoring on dismiss is // selected; show once the pointer is up with a selection; keep it shown while it's being
// automatic — reflow only changes the render rect, not the window state. // interacted with (pointer down but still selected).
let inspector_shown = inspector::is_active(&rc.shared); let active = inspector::is_active(&rc.shared);
let any_down = ui.input(|i| i.pointer.any_down());
if !active {
state.inspector_visible = false;
} else if !any_down {
if !state.inspector_visible {
// Just opened (tap released): anchor to the release position for the reflow test below.
state.inspector_anchor_y = ui
.input(|i| i.pointer.interact_pos())
.map(|p| p.y)
.unwrap_or(region.bottom());
}
state.inspector_visible = true;
}
let mut inspector_shown = state.inspector_visible;
let sheet_h = if inspector_shown { let sheet_h = if inspector_shown {
(region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0) (region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0)
} else { } else {
0.0 0.0
}; };
let sheet_top = region.bottom() - sheet_h; let mut sheet_top = region.bottom() - sheet_h;
let covered = inspector_shown // Tapping outside the sheet (but not on the transport) dismisses the inspector. The press still
&& stack::pane_bottom_in(state, region, inspector::target_slot(&rc.shared)) // reaches the panes below via stack::render, so tapping another object reselects and reopens it.
.map(|bottom| bottom > sheet_top + 1.0) if inspector_shown {
.unwrap_or(false); let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max);
let dismiss = ui.input(|i| {
i.pointer.primary_pressed()
&& i.pointer.press_origin().map_or(false, |p| {
!sheet_rect.contains(p) && !transport_rect.contains(p)
})
});
if dismiss {
rc.shared.selection.clear();
*rc.shared.focus = lightningbeam_core::selection::FocusSelection::None;
state.inspector_visible = false;
inspector_shown = false;
sheet_top = region.bottom();
}
}
// Reflow (shrink the stack above the sheet) only when the thing that was tapped would actually be
// hidden by the sheet — i.e. the tap landed below where the sheet now sits. Otherwise just
// overlay. Restoring on dismiss is automatic — reflow only changes the render rect.
let covered = inspector_shown && state.inspector_anchor_y > sheet_top;
let stack_rect = if covered { let stack_rect = if covered {
egui::Rect::from_min_max(region.min, egui::pos2(region.right(), sheet_top)) egui::Rect::from_min_max(region.min, egui::pos2(region.right(), sheet_top))
} else { } else {
@ -292,4 +333,70 @@ pub fn render_mobile_shell(
// Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last. // Top bar (filename + ⌕ palette + ⋯ commands). Its menus overlay the whole shell, so it's last.
topbar::render(ui, topbar_rect, available_rect, rc, state, &pal); topbar::render(ui, topbar_rect, available_rect, rc, state, &pal);
// Long-press context menu (populated by whichever pane was long-pressed). Persistent popup,
// dispatched via pending_menu_actions.
render_context_menu(ui, available_rect, rc);
}
/// Render the pane-populated long-press context menu (`shared.mobile_context_menu`) as a persistent
/// popup: it stays open until an item is chosen or the user taps outside it. Styled to match the
/// timeline's context menu (default themed popup frame + full-width, touch-height items).
fn render_context_menu(ui: &mut egui::Ui, available: egui::Rect, rc: &mut RenderContext) {
let Some(menu) = rc.shared.mobile_context_menu.clone() else {
return;
};
// Keep the popup on screen (rough clamp against its estimated size).
let est = egui::vec2(200.0, menu.items.len() as f32 * ui.spacing().interact_size.y + 16.0);
let pos = egui::pos2(
menu.pos.x.min(available.right() - est.x - 8.0).max(available.left() + 8.0),
menu.pos.y.min(available.bottom() - est.y - 8.0).max(available.top() + 8.0),
);
let mut chosen: Option<crate::menu::MenuAction> = None;
let area = egui::Area::new(ui.id().with("mobile_ctx_menu"))
.order(egui::Order::Foreground)
.fixed_pos(pos)
.interactable(true)
.show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_min_width(180.0);
for (label, action) in &menu.items {
let w = ui.available_width();
let (rect, resp) =
ui.allocate_exact_size(egui::vec2(w, ui.spacing().interact_size.y), egui::Sense::click());
if ui.is_rect_visible(rect) {
if resp.hovered() {
ui.painter().rect_filled(rect, 2.0, ui.visuals().widgets.hovered.bg_fill);
}
let tc = if resp.hovered() {
ui.visuals().widgets.hovered.text_color()
} else {
ui.visuals().widgets.inactive.text_color()
};
ui.painter().text(
rect.min + egui::vec2(10.0, (rect.height() - 14.0) / 2.0),
egui::Align2::LEFT_TOP,
label,
egui::FontId::proportional(14.0),
tc,
);
}
if resp.clicked() {
chosen = Some(*action);
}
}
});
});
// Dismiss on item choice or a primary click outside the popup. (The secondary click that opened
// it won't dismiss, so there's no just-opened race.)
let primary_click = ui.input(|i| i.pointer.button_clicked(egui::PointerButton::Primary));
let dismiss = chosen.is_some() || (primary_click && !area.response.contains_pointer());
if let Some(action) = chosen {
rc.shared.pending_menu_actions.push(action);
}
if dismiss {
*rc.shared.mobile_context_menu = None;
}
} }

View File

@ -221,6 +221,7 @@ fn nweights(weights: &[f32; 3], count: usize) -> Vec<f32> {
/// The bottom Y of the given stack slot's band within `region` (using the current window + /// The bottom Y of the given stack slot's band within `region` (using the current window +
/// weights), or None if that slot isn't in the visible window. Used to decide whether the /// weights), or None if that slot isn't in the visible window. Used to decide whether the
/// inspector sheet would cover the selected pane. /// inspector sheet would cover the selected pane.
#[allow(dead_code)] // kept for pane-coverage heuristics
pub fn pane_bottom_in(state: &MobileState, region: egui::Rect, slot: usize) -> Option<f32> { pub fn pane_bottom_in(state: &MobileState, region: egui::Rect, slot: usize) -> Option<f32> {
let (top, count) = (state.window_top, state.window_count); let (top, count) = (state.window_top, state.window_count);
if slot < top || slot >= top + count { if slot < top || slot >= top + count {

View File

@ -358,6 +358,18 @@ pub struct SharedPaneState<'a> {
pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>, pub brush_preview_pixels: &'a std::sync::Arc<std::sync::Mutex<Vec<(u32, u32, Vec<u8>)>>>,
/// True when rendering the phone/mobile shell (panes can render more compactly). /// True when rendering the phone/mobile shell (panes can render more compactly).
pub is_mobile: bool, pub is_mobile: bool,
/// Mobile long-press context menu request. A pane sets this on `response.secondary_clicked()`
/// (which fires on long-press) with the items relevant to what was pressed; the mobile shell
/// renders one persistent popup and dispatches the chosen `MenuAction`. `None` = no menu.
pub mobile_context_menu: &'a mut Option<MobileContextMenu>,
}
/// A mobile long-press context menu: a screen position and a list of `(label, action)` items.
/// Rendered by the mobile shell; each item dispatches its `MenuAction` via `pending_menu_actions`.
#[derive(Clone)]
pub struct MobileContextMenu {
pub pos: egui::Pos2,
pub items: Vec<(String, crate::menu::MenuAction)>,
} }
/// Trait for pane rendering /// Trait for pane rendering

View File

@ -3255,6 +3255,14 @@ pub struct StagePane {
// Interaction state // Interaction state
is_panning: bool, is_panning: bool,
last_pan_pos: Option<egui::Pos2>, last_pan_pos: Option<egui::Pos2>,
// Gesture state (P5): double-tap-drag marquee. `last_tap` = (time, canvas pos) of the last
// primary press for double-tap detection; `marquee_gesture_armed` = a double-tap landed on empty
// space and a drag will start a transient marquee regardless of the active tool.
last_tap: Option<(f64, egui::Pos2)>,
marquee_gesture_armed: bool,
/// True once an armed double-tap has actually begun dragging (so we suppress the active tool for
/// the duration of the marquee but NOT for a plain double-tap, which still zooms-to-fit).
marquee_gesture_active: bool,
// Unique ID for this stage instance (for Vello resources) // Unique ID for this stage instance (for Vello resources)
instance_id: u64, instance_id: u64,
// Eyedropper state // Eyedropper state
@ -3743,6 +3751,9 @@ impl StagePane {
needs_initial_center: true, needs_initial_center: true,
is_panning: false, is_panning: false,
last_pan_pos: None, last_pan_pos: None,
last_tap: None,
marquee_gesture_armed: false,
marquee_gesture_active: false,
instance_id, instance_id,
pending_eyedropper_sample: None, pending_eyedropper_sample: None,
last_viewport_rect: None, last_viewport_rect: None,
@ -4030,6 +4041,18 @@ impl StagePane {
// Double-click on empty space while inside a clip: exit // Double-click on empty space while inside a clip: exit
*shared.pending_exit_clip = true; *shared.pending_exit_clip = true;
return; return;
} else {
// Double-tap / double-click on truly empty space (no clip and no shape under the
// pointer, at the top level) → zoom to fit the artboard. (Gesture P5-2.)
let graph_hit = if inbetween {
None
} else {
hit_test::hit_test_layer(vector_layer, *shared.playback_time, point, 5.0, Affine::IDENTITY)
};
if graph_hit.is_none() {
self.zoom_to_fit(shared);
return;
}
} }
} }
@ -11048,6 +11071,75 @@ impl StagePane {
} }
} }
/// True if a clip instance or vector geometry is under `point` (clip-local) on the active layer.
/// Used by the double-tap-drag marquee gesture to decide "empty vs object".
fn point_hits_object(&self, point: vello::kurbo::Point, shared: &SharedPaneState) -> bool {
use lightningbeam_core::hit_test;
use lightningbeam_core::layer::AnyLayer;
use vello::kurbo::Affine;
let Some(active_layer_id) = *shared.active_layer_id else {
return false;
};
let document = shared.action_executor.document();
let Some(AnyLayer::Vector(vl)) = document.get_layer(&active_layer_id) else {
return false;
};
if hit_test::hit_test_clip_instances(&vl.clip_instances, document, point, Affine::IDENTITY, *shared.playback_time).is_some() {
return true;
}
hit_test::hit_test_layer(vl, *shared.playback_time, point, 5.0, Affine::IDENTITY).is_some()
}
/// Delete the currently-selected DCEL edges on the active vector layer (snapshot-based undo).
/// Shared by the Delete/Backspace key and the long-press context menu.
fn delete_selected_geometry(&self, shared: &mut SharedPaneState) {
let Some(active_layer_id) = *shared.active_layer_id else {
return;
};
let time = *shared.playback_time;
let selected_edges: Vec<lightningbeam_core::vector_graph::EdgeId> =
shared.selection.selected_edges().iter().copied().collect();
let selected_fills: Vec<lightningbeam_core::vector_graph::FillId> =
shared.selection.selected_fills().iter().copied().collect();
if selected_edges.is_empty() && selected_fills.is_empty() {
return;
}
let graph_before = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl.graph_at_time(time).cloned(),
_ => None,
};
let Some(graph_before) = graph_before else {
return;
};
{
let document = shared.action_executor.document_mut();
if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) {
if let Some(graph) = vl.graph_at_time_mut(time) {
// Free fills first so their boundary edges become unreferenced and are GC'd.
for fid in &selected_fills {
if !graph.fill(*fid).deleted {
graph.free_fill(*fid);
}
}
for eid in &selected_edges {
graph.remove_edge(*eid);
}
graph.gc_isolated_vertices();
}
}
}
let graph_after = match shared.action_executor.document().get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => vl.graph_at_time(time).cloned(),
_ => None,
};
if let Some(graph_after) = graph_after {
use lightningbeam_core::actions::ModifyGraphAction;
let action = ModifyGraphAction::new(active_layer_id, time, graph_before, graph_after, "Delete");
shared.pending_actions.push(Box::new(action));
}
shared.selection.clear_geometry_selection();
}
fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) { fn handle_input(&mut self, ui: &mut egui::Ui, rect: egui::Rect, shared: &mut SharedPaneState) {
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag()); let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
@ -11314,8 +11406,126 @@ impl StagePane {
} }
} }
// Handle tool input (only if not using Alt modifier for panning) // P5 gesture: double-tap-drag on empty space → transient marquee, regardless of the active
if !alt_held { // tool. The second tap arms it; a drag then drives ToolState::MarqueeSelecting (resolved by
// the release handler above). While armed we suppress the normal tool dispatch so a
// brush/etc. doesn't paint during the gesture. A double-tap without a drag falls through
// (Select tool handles zoom-to-fit).
if !is_replaying && !alt_held {
use vello::kurbo::Point;
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
let press_pos = mouse_canvas_pos.to_pos2();
if self.rsp_primary_pressed(ui) {
let now = ui.input(|i| i.time);
let is_double = self
.last_tap
.map_or(false, |(t, p)| now - t < 0.3 && p.distance(press_pos) < 12.0);
let hits = self.point_hits_object(point, shared);
self.marquee_gesture_armed = is_double && !hits;
self.last_tap = Some((now, press_pos));
}
if self.marquee_gesture_armed && self.rsp_dragged(&response) {
self.marquee_gesture_active = true;
match shared.tool_state {
ToolState::MarqueeSelecting { start, .. } => {
*shared.tool_state = ToolState::MarqueeSelecting { start: *start, current: point };
}
_ => {
if !shift_held {
shared.selection.clear();
*shared.focus = lightningbeam_core::selection::FocusSelection::None;
}
*shared.tool_state = ToolState::MarqueeSelecting { start: point, current: point };
}
}
}
if ui.input(|i| i.pointer.any_released()) {
self.marquee_gesture_armed = false;
self.marquee_gesture_active = false;
}
}
// P5 gesture (mobile only): long-press on an object opens its actions menu. In the egui fork
// `secondary_clicked()` fires on long-press (and right-click) and `context_menu()` opens on
// the same — so the menu must be attached UNCONDITIONALLY for egui to register it. Desktop
// (flag off) has no Stage context menu.
if shared.is_mobile {
use lightningbeam_core::layer::AnyLayer;
use lightningbeam_core::selection::FocusSelection;
use vello::kurbo::{Affine, Point};
// Long-press selects the object under the press (clip instance first, else geometry).
if response.secondary_clicked() {
if let Some(layer_id) = *shared.active_layer_id {
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
let time = *shared.playback_time;
let clip_hit = {
let document = shared.action_executor.document();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
lightningbeam_core::hit_test::hit_test_clip_instances(&vl.clip_instances, document, point, Affine::IDENTITY, time)
} else {
None
}
};
if let Some(instance_id) = clip_hit {
if !shared.selection.contains_clip_instance(&instance_id) {
shared.selection.select_only_clip_instance(instance_id);
*shared.focus = FocusSelection::ClipInstances(shared.selection.clip_instances().to_vec());
}
} else {
// No clip — try vector geometry (edge/fill).
let graph_hit = {
let document = shared.action_executor.document();
if let Some(AnyLayer::Vector(vl)) = document.get_layer(&layer_id) {
lightningbeam_core::hit_test::hit_test_layer(vl, time, point, 5.0, Affine::IDENTITY)
} else {
None
}
};
if let Some(gh) = graph_hit {
if let Some(AnyLayer::Vector(vl)) = shared.action_executor.document().get_layer(&layer_id) {
if let Some(graph) = vl.graph_at_time(time) {
shared.selection.clear_geometry_selection();
match gh {
lightningbeam_core::hit_test::GraphHitResult::Edge(eid) => shared.selection.select_edge(eid, graph),
lightningbeam_core::hit_test::GraphHitResult::Fill(fid) => shared.selection.select_fill(fid, graph),
}
}
}
*shared.focus = FocusSelection::Geometry { layer_id, time };
}
}
}
}
// Build the menu items for the current selection and hand them to the shell to render
// as a persistent popup (stays open until an item or the backdrop is tapped).
if response.secondary_clicked() {
use crate::menu::MenuAction;
let mut items: Vec<(String, MenuAction)> = Vec::new();
if !shared.selection.clip_instances().is_empty() {
// A clip instance is already a clip — no "convert" here.
items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy));
items.push(("Duplicate".into(), MenuAction::DuplicateClip));
items.push(("Delete".into(), MenuAction::Delete));
} else if shared.selection.has_geometry_selection() {
// Geometry can be gathered up into a movie clip.
items.push(("Cut".into(), MenuAction::Cut));
items.push(("Copy".into(), MenuAction::Copy));
items.push(("Convert to movie clip".into(), MenuAction::ConvertToMovieClip));
items.push(("Delete".into(), MenuAction::Delete));
}
if !items.is_empty() {
if let Some(pos) = ui.input(|i| i.pointer.interact_pos()) {
*shared.mobile_context_menu = Some(crate::panes::MobileContextMenu { pos, items });
}
}
}
}
// Handle tool input (only if not using Alt modifier for panning, and not while a
// double-tap-drag marquee is actively dragging so the tool doesn't act during the gesture).
if !alt_held && !self.marquee_gesture_active {
use lightningbeam_core::tool::Tool; use lightningbeam_core::tool::Tool;
// On a shape-tween in-between frame the active vector layer's geometry is an // On a shape-tween in-between frame the active vector layer's geometry is an
@ -11425,70 +11635,26 @@ impl StagePane {
// Delete/Backspace: remove selected DCEL elements // Delete/Backspace: remove selected DCEL elements
if ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::StageDelete, i)) { if ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::StageDelete, i)) {
if shared.selection.has_geometry_selection() { if shared.selection.has_geometry_selection() {
if let Some(active_layer_id) = *shared.active_layer_id { self.delete_selected_geometry(shared);
let time = *shared.playback_time;
// Collect selected edge IDs before mutating
let selected_edges: Vec<lightningbeam_core::vector_graph::EdgeId> =
shared.selection.selected_edges().iter().copied().collect();
if !selected_edges.is_empty() {
// Snapshot before
let graph_before = {
let document = shared.action_executor.document();
match document.get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => {
vl.graph_at_time(time).cloned()
}
_ => None,
}
};
if let Some(graph_before) = graph_before {
// Remove selected edges
{
let document = shared.action_executor.document_mut();
if let Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) = document.get_layer_mut(&active_layer_id) {
if let Some(graph) = vl.graph_at_time_mut(time) {
for eid in &selected_edges {
graph.remove_edge(*eid);
}
}
}
}
// Snapshot after
let graph_after = {
let document = shared.action_executor.document();
match document.get_layer(&active_layer_id) {
Some(lightningbeam_core::layer::AnyLayer::Vector(vl)) => {
vl.graph_at_time(time).cloned()
}
_ => None,
}
};
if let Some(graph_after) = graph_after {
use lightningbeam_core::actions::ModifyGraphAction;
let action = ModifyGraphAction::new(
active_layer_id,
time,
graph_before,
graph_after,
"Delete",
);
shared.pending_actions.push(Box::new(action));
}
shared.selection.clear_geometry_selection();
}
}
}
} }
} }
// Skip real scroll/zoom/pan input during replay // Skip real scroll/zoom/pan input during replay
if !is_replaying { if !is_replaying {
// Unified pinch / Ctrl+scroll zoom: `zoom_delta()` folds in touch-pinch (on device) AND
// Ctrl+wheel/trackpad (on desktop). It's a multiplicative factor (1.0 = no change);
// `apply_zoom_at_point` wants an additive delta (new = old * (1 + delta)). This is the
// single path for factor-zoom — the raw MouseWheel branch below only handles plain
// (non-Ctrl) mouse-wheel zoom and non-Ctrl trackpad pan.
let zoom_factor = ui.input(|i| i.zoom_delta());
if (zoom_factor - 1.0).abs() > f32::EPSILON {
let center = ui
.input(|i| i.multi_touch().map(|m| m.center_pos))
.map(|p| p - rect.min)
.unwrap_or(mouse_canvas_pos);
self.apply_zoom_at_point(zoom_factor - 1.0, center);
}
// Distinguish between mouse wheel (discrete) and trackpad (smooth) // Distinguish between mouse wheel (discrete) and trackpad (smooth)
let mut handled = false; let mut handled = false;
ui.input(|i| { ui.input(|i| {
@ -11496,23 +11662,20 @@ impl StagePane {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
match unit { match unit {
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
// Real mouse wheel (discrete clicks) -> always zoom // Plain mouse wheel zooms; Ctrl+wheel was already handled above via
let zoom_delta = if ctrl_held || modifiers.ctrl { // zoom_delta(). Consume the event either way so the pan fallback
delta.y * 0.01 // Ctrl+wheel: faster zoom // below doesn't also fire.
} else { if !(ctrl_held || modifiers.ctrl) {
delta.y * 0.005 // Normal zoom self.apply_zoom_at_point(delta.y * 0.005, mouse_canvas_pos);
}; }
self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos);
handled = true; handled = true;
} }
egui::MouseWheelUnit::Point => { egui::MouseWheelUnit::Point => {
// Trackpad (smooth scrolling) -> only zoom if Ctrl held // Trackpad: Ctrl-zoom handled above (consume it); non-Ctrl falls
// through to scroll_delta panning.
if ctrl_held || modifiers.ctrl { if ctrl_held || modifiers.ctrl {
let zoom_delta = delta.y * 0.005;
self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos);
handled = true; handled = true;
} }
// Otherwise let scroll_delta handle panning
} }
} }
} }

View File

@ -1481,6 +1481,15 @@ impl TimelinePane {
self.pixels_per_second = 100.0; self.pixels_per_second = 100.0;
} }
/// Fit the whole project duration into the given content width, scrolled to the start.
/// (Gesture P5-2: double-tap empty → zoom-to-fit.)
pub fn fit_to_project(&mut self, viewport_width: f32) {
let dur = self.duration.max(0.01);
self.pixels_per_second =
(viewport_width / dur as f32).clamp(MIN_PIXELS_PER_SECOND, MAX_PIXELS_PER_SECOND);
self.viewport_start_time = 0.0;
}
/// Apply zoom while keeping the time under the cursor stationary /// Apply zoom while keeping the time under the cursor stationary
fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) { fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) {
let old_zoom = self.pixels_per_second; let old_zoom = self.pixels_per_second;
@ -4964,29 +4973,40 @@ impl TimelinePane {
// Only handle scroll when mouse is over the timeline area // Only handle scroll when mouse is over the timeline area
let mut handled = false; let mut handled = false;
let pointer_over_timeline = response.hovered() || ui.rect_contains_pointer(header_rect); let pointer_over_timeline = response.hovered() || ui.rect_contains_pointer(header_rect);
// Unified pinch / Ctrl+scroll time-zoom: `zoom_delta()` folds in touch-pinch (on device) AND
// Ctrl+wheel/trackpad (on desktop). Multiplicative factor (1.0 = no change); apply_zoom_at_point
// wants an additive delta. This is the single path for factor-zoom — the raw MouseWheel branch
// below only handles plain (non-Ctrl) mouse-wheel zoom and non-Ctrl trackpad pan.
if pointer_over_timeline {
let zoom_factor = ui.input(|i| i.zoom_delta());
if (zoom_factor - 1.0).abs() > f32::EPSILON {
let center_x = ui
.input(|i| i.multi_touch().map(|m| m.center_pos.x))
.map(|x| x - content_rect.min.x)
.unwrap_or(mouse_x);
self.apply_zoom_at_point(zoom_factor - 1.0, center_x);
}
}
if pointer_over_timeline { ui.input(|i| { if pointer_over_timeline { ui.input(|i| {
for event in &i.raw.events { for event in &i.raw.events {
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
match unit { match unit {
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
// Real mouse wheel (discrete clicks) -> always zoom horizontally // Plain mouse wheel zooms; Ctrl+wheel handled above via zoom_delta().
let zoom_delta = if ctrl_held || modifiers.ctrl { // Consume the event either way so the pan fallback doesn't also fire.
delta.y * 0.01 // Ctrl+wheel: faster zoom if !(ctrl_held || modifiers.ctrl) {
} else { self.apply_zoom_at_point(delta.y * 0.005, mouse_x);
delta.y * 0.005 // Normal zoom }
};
self.apply_zoom_at_point(zoom_delta, mouse_x);
handled = true; handled = true;
} }
egui::MouseWheelUnit::Point => { egui::MouseWheelUnit::Point => {
// Trackpad (smooth scrolling) // Trackpad: Ctrl-zoom handled above (consume it); non-Ctrl falls through
// to scroll_delta panning below.
if ctrl_held || modifiers.ctrl { 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; handled = true;
} }
// Otherwise let scroll_delta handle panning (below)
} }
} }
} }
@ -5029,6 +5049,18 @@ impl TimelinePane {
} }
} }
// Double-tap / double-click on empty space → fit the whole project into view. (Gesture P5-2.)
if response.double_clicked() {
if let Some(pos) = response.interact_pointer_pos() {
let over_clip = self
.detect_clip_at_pointer(pos, document, content_rect, header_rect, editing_clip_id, &audio_cache)
.is_some();
if !over_clip {
self.fit_to_project(content_rect.width());
}
}
}
// Update cursor based on hover position (only if not scrubbing or panning) // Update cursor based on hover position (only if not scrubbing or panning)
if !self.is_scrubbing && !self.is_panning { if !self.is_scrubbing && !self.is_panning {
// If dragging a clip with trim/loop, keep the appropriate cursor // If dragging a clip with trim/loop, keep the appropriate cursor