diff --git a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs index 646fe31..16cc15f 100644 --- a/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs +++ b/lightningbeam-ui/lightningbeam-core/src/vector_graph/mod.rs @@ -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 = (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 // ------------------------------------------------------------------- diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 00772b2..94621dc 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -959,6 +959,8 @@ struct EditorApp { mobile_ui_override: Option, /// 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, 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 = self.selection.selected_edges().iter().copied().collect(); + let fill_ids: Vec = + 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. diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs index 5c23319..cfec83d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs @@ -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( diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index b49863c..9601894 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -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 = 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; + } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs index 5921527..ad47a8e 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/stack.rs @@ -221,6 +221,7 @@ fn nweights(weights: &[f32; 3], count: usize) -> Vec { /// 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 { let (top, count) = (state.window_top, state.window_count); if slot < top || slot >= top + count { diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index d1f1fe4..ef40988 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -358,6 +358,18 @@ pub struct SharedPaneState<'a> { pub brush_preview_pixels: &'a std::sync::Arc)>>>, /// 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, +} + +/// 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 diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index a34094f..bb4078d 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -3255,6 +3255,14 @@ pub struct StagePane { // Interaction state is_panning: bool, last_pan_pos: Option, + // 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 = + shared.selection.selected_edges().iter().copied().collect(); + let selected_fills: Vec = + 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 = - 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 } } } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs index 158ec7b..d04028a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/timeline.rs @@ -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