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:
parent
4b2802076b
commit
01b20165cc
|
|
@ -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
|
||||
// -------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -959,6 +959,8 @@ struct EditorApp {
|
|||
mobile_ui_override: Option<bool>,
|
||||
/// Persistent state for the mobile shell (active surface, ribbon tier).
|
||||
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,
|
||||
tool_icon_cache: ToolIconCache,
|
||||
focus_icon_cache: FocusIconCache, // Focus card icons (start screen)
|
||||
|
|
@ -1320,6 +1322,7 @@ impl EditorApp {
|
|||
mobile_ui: mobile::is_mobile_env(),
|
||||
mobile_ui_override: None,
|
||||
mobile_state: mobile::MobileState::default(),
|
||||
mobile_context_menu: None,
|
||||
icon_cache: IconCache::new(),
|
||||
tool_icon_cache: ToolIconCache::new(),
|
||||
focus_icon_cache: FocusIconCache::new(),
|
||||
|
|
@ -2681,45 +2684,56 @@ impl EditorApp {
|
|||
}
|
||||
|
||||
self.selection.clear_clip_instances();
|
||||
self.focus = lightningbeam_core::selection::FocusSelection::None;
|
||||
} else if self.selection.has_geometry_selection() {
|
||||
let active_layer_id = match self.active_layer_id {
|
||||
Some(id) => id,
|
||||
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> =
|
||||
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();
|
||||
if let Some(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) {
|
||||
let mut graph_after = graph_before.clone();
|
||||
for edge_id in &edge_ids {
|
||||
if !graph_after.edge(*edge_id).deleted {
|
||||
graph_after.remove_edge(*edge_id);
|
||||
}
|
||||
if let Some(lightningbeam_core::layer::AnyLayer::Vector(vector_layer)) = document.get_layer(&active_layer_id) {
|
||||
if let Some(graph_before) = vector_layer.graph_at_time(self.playback_time) {
|
||||
let mut graph_after = graph_before.clone();
|
||||
// Free fills first so their boundary edges become unreferenced and are GC'd.
|
||||
for fill_id in &fill_ids {
|
||||
if !graph_after.fill(*fill_id).deleted {
|
||||
graph_after.free_fill(*fill_id);
|
||||
}
|
||||
|
||||
let action = lightningbeam_core::actions::ModifyGraphAction::new(
|
||||
active_layer_id,
|
||||
self.playback_time,
|
||||
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);
|
||||
}
|
||||
for edge_id in &edge_ids {
|
||||
if !graph_after.edge(*edge_id).deleted {
|
||||
graph_after.remove_edge(*edge_id);
|
||||
}
|
||||
}
|
||||
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.focus = lightningbeam_core::selection::FocusSelection::None;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -7182,6 +7196,7 @@ impl EditorApp {
|
|||
rc: RenderContext {
|
||||
shared: panes::SharedPaneState {
|
||||
is_mobile,
|
||||
mobile_context_menu: &mut self.mobile_context_menu,
|
||||
container_path: self.current_file_path.clone(),
|
||||
onion: {
|
||||
// Onion skinning is disabled during playback.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// 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 {
|
||||
match &*shared.focus {
|
||||
FocusSelection::Notes { .. } => 4, // PianoRoll
|
||||
|
|
@ -99,13 +100,11 @@ pub fn render(
|
|||
state: &mut MobileState,
|
||||
pal: &Palette,
|
||||
) {
|
||||
// Sheet background with rounded top.
|
||||
ui.painter().rect_filled(
|
||||
rect,
|
||||
egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 },
|
||||
pal.surface_alt,
|
||||
);
|
||||
ui.painter().hline(rect.x_range(), rect.top(), egui::Stroke::new(1.0, pal.line));
|
||||
// Sheet background with rounded top + a border stroke that follows the rounded corners (a plain
|
||||
// hline would overrun the rounded corners and detach from them).
|
||||
let radius = egui::CornerRadius { nw: 14, ne: 14, sw: 0, se: 0 };
|
||||
ui.painter().rect_filled(rect, radius, pal.surface_alt);
|
||||
ui.painter().rect_stroke(rect, radius, egui::Stroke::new(1.0, pal.line), egui::StrokeKind::Inside);
|
||||
|
||||
// Grab handle — drag to resize the sheet.
|
||||
let grab = egui::Rect::from_min_max(
|
||||
|
|
|
|||
|
|
@ -191,6 +191,12 @@ pub struct MobileState {
|
|||
pub show_instruments: bool,
|
||||
/// Inspector sheet height as a fraction of the region above the transport.
|
||||
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.
|
||||
pub omni_open: bool,
|
||||
/// Whether the omnibutton "more" grid (all tools) is open.
|
||||
|
|
@ -217,6 +223,8 @@ impl Default for MobileState {
|
|||
weights: [1.0, 1.0, 1.0],
|
||||
show_instruments: false,
|
||||
inspector_frac: 0.45,
|
||||
inspector_visible: false,
|
||||
inspector_anchor_y: 0.0,
|
||||
omni_open: false,
|
||||
omni_grid_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.right(), transport_rect.top()),
|
||||
);
|
||||
// When the inspector is up, the sheet overlays the lower part of the stack. If the selected
|
||||
// pane would be *covered* by the sheet, reflow (shrink the stack above the sheet) so it stays
|
||||
// visible; otherwise leave the stack full-height and just overlay. Restoring on dismiss is
|
||||
// automatic — reflow only changes the render rect, not the window state.
|
||||
let inspector_shown = inspector::is_active(&rc.shared);
|
||||
// The inspector sheet appears on pointer *release* (a tap), not on press — so press+drag on the
|
||||
// canvas isn't interrupted by the sheet popping up. Track visibility: hide when nothing's
|
||||
// selected; show once the pointer is up with a selection; keep it shown while it's being
|
||||
// interacted with (pointer down but still selected).
|
||||
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 {
|
||||
(region.height() * state.inspector_frac).clamp(120.0, region.height() - 60.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let sheet_top = region.bottom() - sheet_h;
|
||||
let mut sheet_top = region.bottom() - sheet_h;
|
||||
|
||||
let covered = inspector_shown
|
||||
&& stack::pane_bottom_in(state, region, inspector::target_slot(&rc.shared))
|
||||
.map(|bottom| bottom > sheet_top + 1.0)
|
||||
.unwrap_or(false);
|
||||
// Tapping outside the sheet (but not on the transport) dismisses the inspector. The press still
|
||||
// reaches the panes below via stack::render, so tapping another object reselects and reopens it.
|
||||
if inspector_shown {
|
||||
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 {
|
||||
egui::Rect::from_min_max(region.min, egui::pos2(region.right(), sheet_top))
|
||||
} 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.
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 +
|
||||
/// weights), or None if that slot isn't in the visible window. Used to decide whether the
|
||||
/// 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> {
|
||||
let (top, count) = (state.window_top, state.window_count);
|
||||
if slot < top || slot >= top + count {
|
||||
|
|
|
|||
|
|
@ -358,6 +358,18 @@ pub struct SharedPaneState<'a> {
|
|||
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).
|
||||
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
|
||||
|
|
|
|||
|
|
@ -3255,6 +3255,14 @@ pub struct StagePane {
|
|||
// Interaction state
|
||||
is_panning: bool,
|
||||
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)
|
||||
instance_id: u64,
|
||||
// Eyedropper state
|
||||
|
|
@ -3743,6 +3751,9 @@ impl StagePane {
|
|||
needs_initial_center: true,
|
||||
is_panning: false,
|
||||
last_pan_pos: None,
|
||||
last_tap: None,
|
||||
marquee_gesture_armed: false,
|
||||
marquee_gesture_active: false,
|
||||
instance_id,
|
||||
pending_eyedropper_sample: None,
|
||||
last_viewport_rect: None,
|
||||
|
|
@ -4030,6 +4041,18 @@ impl StagePane {
|
|||
// Double-click on empty space while inside a clip: exit
|
||||
*shared.pending_exit_clip = true;
|
||||
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) {
|
||||
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)
|
||||
if !alt_held {
|
||||
// P5 gesture: double-tap-drag on empty space → transient marquee, regardless of the active
|
||||
// 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;
|
||||
|
||||
// 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
|
||||
if ui.input(|i| shared.keymap.action_pressed_with_backspace(crate::keymap::AppAction::StageDelete, i)) {
|
||||
if shared.selection.has_geometry_selection() {
|
||||
if let Some(active_layer_id) = *shared.active_layer_id {
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
self.delete_selected_geometry(shared);
|
||||
}
|
||||
}
|
||||
|
||||
// Skip real scroll/zoom/pan input during replay
|
||||
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)
|
||||
let mut handled = false;
|
||||
ui.input(|i| {
|
||||
|
|
@ -11496,23 +11662,20 @@ impl StagePane {
|
|||
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
|
||||
match unit {
|
||||
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
|
||||
// Real mouse wheel (discrete clicks) -> always zoom
|
||||
let zoom_delta = if ctrl_held || modifiers.ctrl {
|
||||
delta.y * 0.01 // Ctrl+wheel: faster zoom
|
||||
} else {
|
||||
delta.y * 0.005 // Normal zoom
|
||||
};
|
||||
self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos);
|
||||
// Plain mouse wheel zooms; Ctrl+wheel was already handled above via
|
||||
// zoom_delta(). Consume the event either way so the pan fallback
|
||||
// below doesn't also fire.
|
||||
if !(ctrl_held || modifiers.ctrl) {
|
||||
self.apply_zoom_at_point(delta.y * 0.005, mouse_canvas_pos);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
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 {
|
||||
let zoom_delta = delta.y * 0.005;
|
||||
self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos);
|
||||
handled = true;
|
||||
}
|
||||
// Otherwise let scroll_delta handle panning
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1481,6 +1481,15 @@ impl TimelinePane {
|
|||
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
|
||||
fn apply_zoom_at_point(&mut self, zoom_delta: f32, mouse_x: f32) {
|
||||
let old_zoom = self.pixels_per_second;
|
||||
|
|
@ -4964,29 +4973,40 @@ impl TimelinePane {
|
|||
// Only handle scroll when mouse is over the timeline area
|
||||
let mut handled = false;
|
||||
let pointer_over_timeline = response.hovered() || ui.rect_contains_pointer(header_rect);
|
||||
|
||||
// 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| {
|
||||
for event in &i.raw.events {
|
||||
if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event {
|
||||
match unit {
|
||||
egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => {
|
||||
// Real mouse wheel (discrete clicks) -> always zoom horizontally
|
||||
let zoom_delta = if ctrl_held || modifiers.ctrl {
|
||||
delta.y * 0.01 // Ctrl+wheel: faster zoom
|
||||
} else {
|
||||
delta.y * 0.005 // Normal zoom
|
||||
};
|
||||
self.apply_zoom_at_point(zoom_delta, mouse_x);
|
||||
// Plain mouse wheel zooms; Ctrl+wheel handled above via zoom_delta().
|
||||
// Consume the event either way so the pan fallback doesn't also fire.
|
||||
if !(ctrl_held || modifiers.ctrl) {
|
||||
self.apply_zoom_at_point(delta.y * 0.005, mouse_x);
|
||||
}
|
||||
handled = true;
|
||||
}
|
||||
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 {
|
||||
// Ctrl held: zoom
|
||||
let zoom_delta = delta.y * 0.005;
|
||||
self.apply_zoom_at_point(zoom_delta, mouse_x);
|
||||
handled = true;
|
||||
}
|
||||
// Otherwise let scroll_delta handle panning (below)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
if !self.is_scrubbing && !self.is_panning {
|
||||
// If dragging a clip with trim/loop, keep the appropriate cursor
|
||||
|
|
|
|||
Loading…
Reference in New Issue