From 4f58edf436c54b6da410e9d9a728d820c79bed7b Mon Sep 17 00:00:00 2001 From: Skyler Lehmkuhl Date: Thu, 2 Jul 2026 01:14:34 -0400 Subject: [PATCH] P5 follow-ups: double-tap priority, inspector dismiss, breadcrumb path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mobile double-tap is now a single tool-independent priority chain: object → enter (movie clip or group); empty inside a clip → exit one level; empty at root → zoom-to-fit. Desktop keeps its own select-tool double-click (gated). - Inspector "tap outside to dismiss" now only HIDES the sheet (with a dismissed flag reset on selection change) instead of clearing the selection — the earlier clear clobbered the selection before menu/omnibutton actions ran, breaking Group/Convert/Cut/Copy. Delete also clears focus so it dismisses the inspector. - Breadcrumb shows the full editing path (Scene 1 › … › current) via a new SharedPaneState.editing_clip_path / EditingContext::clip_path(); each ancestor crumb jumps to that level (pending_exit_to_depth → EditingContext::exit_to_depth). - Remove the "Vello Stage" debug overlay; move the breadcrumb up to the top-left. --- .../lightningbeam-editor/src/main.rs | 27 +++ .../src/mobile/inspector.rs | 16 ++ .../lightningbeam-editor/src/mobile/mod.rs | 24 ++- .../lightningbeam-editor/src/panes/mod.rs | 5 + .../lightningbeam-editor/src/panes/stage.rs | 161 +++++++++++------- 5 files changed, 163 insertions(+), 70 deletions(-) diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 94621dc..ee2ca62 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -942,6 +942,21 @@ impl EditingContext { self.stack.pop() } + /// The clip_id path from outermost to the current level (for breadcrumbs). + fn clip_path(&self) -> Vec { + self.stack.iter().map(|e| e.clip_id).collect() + } + + /// Pop down to `depth` entries, returning the entry at `depth` (whose saved state should be + /// restored). Returns None if already at or above that depth. + fn exit_to_depth(&mut self, depth: usize) -> Option { + if depth >= self.stack.len() { + return None; + } + let restore = self.stack[depth].clone(); + self.stack.truncate(depth); + Some(restore) + } } struct EditorApp { @@ -6926,6 +6941,15 @@ impl eframe::App for EditorApp { } } + // Breadcrumb jump: exit up to a specific depth (restoring that level's saved context). + if let Some(depth) = scratch.pending_exit_to_depth.take() { + if let Some(entry) = self.editing_context.exit_to_depth(depth) { + self.selection.clear(); + self.active_layer_id = entry.saved_active_layer_id; + self.playback_time = entry.saved_playback_time; + } + } + // Set cursor based on hover state if let Some((_, is_horizontal)) = self.hovered_divider { if is_horizontal { @@ -7157,6 +7181,7 @@ struct FrameScratch { pending_handlers: Vec, pending_enter_clip: Option<(Uuid, Uuid, Uuid)>, pending_exit_clip: bool, + pending_exit_to_depth: Option, pending_actions: Vec>, pending_menu_actions: Vec, effect_thumbnail_requests: Vec, @@ -7221,8 +7246,10 @@ impl EditorApp { editing_clip_id: self.editing_context.current_clip_id(), editing_instance_id: self.editing_context.current_instance_id(), editing_parent_layer_id: self.editing_context.current_parent_layer_id(), + editing_clip_path: self.editing_context.clip_path(), pending_enter_clip: &mut scratch.pending_enter_clip, pending_exit_clip: &mut scratch.pending_exit_clip, + pending_exit_to_depth: &mut scratch.pending_exit_to_depth, active_layer_id: &mut self.active_layer_id, tool_state: &mut self.tool_state, pending_actions: &mut scratch.pending_actions, diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs index cfec83d..9edbfb1 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/inspector.rs @@ -22,6 +22,22 @@ pub fn is_active(shared: &SharedPaneState) -> bool { !shared.focus.is_none() || !shared.selection.is_empty() } +/// A cheap content-sensitive signature of the current selection, so the shell can detect when the +/// selection *changes* (to re-show a manually-dismissed inspector for the new thing). +pub fn selection_sig(shared: &SharedPaneState) -> u64 { + let mut h: u64 = 0; + for id in shared.selection.clip_instances() { + h ^= (id.as_u128() as u64).wrapping_mul(0x9E3779B97F4A7C15); + } + for f in shared.selection.selected_fills() { + h ^= (f.idx() as u64).wrapping_mul(0xD1B54A32D192ED03); + } + for e in shared.selection.selected_edges() { + h ^= (e.idx() as u64).wrapping_mul(0xA24BAED4963EE407); + } + h +} + /// 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 diff --git a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs index 9601894..69c2d3a 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/mobile/mod.rs @@ -197,6 +197,11 @@ pub struct MobileState { /// 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, + /// Set when the user taps outside the sheet to dismiss it; suppresses re-showing until the + /// selection changes. Reset when the selection signature below changes. + pub inspector_dismissed: bool, + /// Cheap signature of the current selection, to detect when it changes (re-show the inspector). + pub inspector_sel_sig: u64, /// Whether the omnibutton radial tool menu is open. pub omni_open: bool, /// Whether the omnibutton "more" grid (all tools) is open. @@ -225,6 +230,8 @@ impl Default for MobileState { inspector_frac: 0.45, inspector_visible: false, inspector_anchor_y: 0.0, + inspector_dismissed: false, + inspector_sel_sig: 0, omni_open: false, omni_grid_open: false, omni_create_open: false, @@ -269,9 +276,16 @@ pub fn render_mobile_shell( // interacted with (pointer down but still selected). let active = inspector::is_active(&rc.shared); let any_down = ui.input(|i| i.pointer.any_down()); + let sel_sig = inspector::selection_sig(&rc.shared); + if sel_sig != state.inspector_sel_sig { + // Selection changed → a fresh thing to inspect; clear any prior manual dismissal. + state.inspector_dismissed = false; + state.inspector_sel_sig = sel_sig; + } if !active { state.inspector_visible = false; - } else if !any_down { + state.inspector_dismissed = false; + } else if !state.inspector_dismissed && !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 @@ -289,8 +303,9 @@ pub fn render_mobile_shell( }; let mut sheet_top = region.bottom() - sheet_h; - // 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. + // Tapping outside the sheet (but not on the transport) dismisses (hides) the inspector. We only + // hide it — NOT clear the selection — so actions dispatched from overlays (context menu, "+New" + // grid) still see the selection. It stays dismissed until the selection changes (sig above). if inspector_shown { let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max); let dismiss = ui.input(|i| { @@ -300,9 +315,8 @@ pub fn render_mobile_shell( }) }); if dismiss { - rc.shared.selection.clear(); - *rc.shared.focus = lightningbeam_core::selection::FocusSelection::None; state.inspector_visible = false; + state.inspector_dismissed = true; inspector_shown = false; sheet_top = region.bottom(); } diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index ef40988..a345def 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -212,10 +212,15 @@ pub struct SharedPaneState<'a> { pub editing_instance_id: Option, /// The parent layer ID containing the clip instance being edited pub editing_parent_layer_id: Option, + /// The full clip_id path being edited, outermost → current (for breadcrumbs). Empty at root. + pub editing_clip_path: Vec, /// Request to enter a movie clip for editing: (clip_id, instance_id, parent_layer_id) pub pending_enter_clip: &'a mut Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)>, /// Request to exit the current movie clip pub pending_exit_clip: &'a mut bool, + /// Request to exit up to a specific editing depth (number of clips to keep); e.g. a breadcrumb + /// click. `Some(0)` exits all the way to the document root. + pub pending_exit_to_depth: &'a mut Option, /// Currently active layer ID pub active_layer_id: &'a mut Option, /// Current tool interaction state (mutable for tools to modify) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index bb4078d..01adcd7 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -4009,8 +4009,9 @@ impl StagePane { // geometry selection/editing there; clip-instance interaction stays available. let inbetween = vector_layer.is_tween_inbetween(*shared.playback_time); - // Double-click: enter/exit movie clip editing - if response.double_clicked() { + // Double-click: enter/exit movie clip editing (desktop; mobile handles double-tap as a + // single tool-independent gesture in handle_input). + if !shared.is_mobile && response.double_clicked() { // Hit test clip instances at the click position let document = shared.action_executor.document(); let clip_hit = hit_test::hit_test_clip_instances( @@ -11445,6 +11446,36 @@ impl StagePane { } } + // P5 gesture (mobile): double-tap semantics, in priority order and regardless of the active + // tool. (double_clicked() fires on double-tap and, on desktop, double-click — so testable.) + // 1. object under the tap → enter it (movie clip or group — go a level deeper) + // 2. empty, inside a clip → exit one level + // 3. empty, at the top level → zoom to fit + // The desktop equivalent lives in handle_select_tool (gated to !is_mobile below). + if shared.is_mobile && response.double_clicked() { + use lightningbeam_core::layer::AnyLayer; + use vello::kurbo::{Affine, Point}; + let point = Point::new(world_pos.x as f64, world_pos.y as f64); + let hit = (*shared.active_layer_id).and_then(|layer_id| { + 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, *shared.playback_time, + ) + .and_then(|iid| vl.clip_instances.iter().find(|c| c.id == iid).map(|c| (c.clip_id, iid, layer_id))) + } else { + None + } + }); + if let Some((clip_id, instance_id, layer_id)) = hit { + *shared.pending_enter_clip = Some((clip_id, instance_id, layer_id)); + } else if shared.editing_clip_id.is_some() { + *shared.pending_exit_clip = true; + } else { + self.zoom_to_fit(shared); + } + } + // 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 @@ -12865,72 +12896,72 @@ impl PaneRenderer for StagePane { } } - // Show camera info overlay - let info_color = shared.theme.text_color(&["#stage", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); - ui.painter().text( - rect.min + egui::vec2(10.0, 10.0), - egui::Align2::LEFT_TOP, - format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})", - self.zoom, self.pan_offset.x, self.pan_offset.y), - egui::FontId::proportional(14.0), - info_color, - ); - - // Render breadcrumb navigation when inside a movie clip - if shared.editing_clip_id.is_some() { - let document = shared.action_executor.document(); - // Build breadcrumb names from the editing context - // We only have the current clip_id, so show "Scene 1 > ClipName" - let clip_name = shared.editing_clip_id - .and_then(|id| document.get_vector_clip(&id)) - .map(|c| c.name.clone()) - .unwrap_or_else(|| "Unknown".to_string()); - - let breadcrumb_y = rect.min.y + 30.0; - let breadcrumb_x = rect.min.x + 10.0; - - // Background pill - let scene_text = "Scene 1"; - let separator = " > "; - let full_text = format!("{}{}{}", scene_text, separator, clip_name); + // Render breadcrumb navigation showing the full editing path (root → … → current clip). + if !shared.editing_clip_path.is_empty() { let font = egui::FontId::proportional(13.0); - let galley = ui.painter().layout_no_wrap(full_text.clone(), font.clone(), egui::Color32::WHITE); - let text_rect = egui::Rect::from_min_size( - egui::pos2(breadcrumb_x, breadcrumb_y), - galley.size() + egui::vec2(16.0, 8.0), - ); - ui.painter().rect_filled( - text_rect, - 4.0, - egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180), - ); + let sep = " > "; + // Segments: "Scene 1" (root) + each clip name. `target` = depth to exit to when clicked + // (root → 0; clip at path index k → k+1); the current (last) level isn't clickable. + let segments: Vec<(String, Option)> = { + let document = shared.action_executor.document(); + let n = shared.editing_clip_path.len(); + let mut segs: Vec<(String, Option)> = vec![("Scene 1".to_string(), Some(0))]; + for (k, cid) in shared.editing_clip_path.iter().enumerate() { + let name = document + .get_vector_clip(cid) + .map(|c| c.name.clone()) + .unwrap_or_else(|| "Unknown".to_string()); + segs.push((name, if k + 1 == n { None } else { Some(k + 1) })); + } + segs + }; - // "Scene 1" as clickable (exit clip) - let scene_galley = ui.painter().layout_no_wrap( - scene_text.to_string(), font.clone(), egui::Color32::from_rgb(120, 180, 255), - ); - let scene_rect = egui::Rect::from_min_size( - egui::pos2(breadcrumb_x + 8.0, breadcrumb_y + 4.0), - scene_galley.size(), - ); - let scene_response = ui.allocate_rect(scene_rect, egui::Sense::click()); - ui.painter().galley(scene_rect.min, scene_galley, egui::Color32::WHITE); - if scene_response.clicked() { - *shared.pending_exit_clip = true; - } - if scene_response.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); - } + let bx = rect.min.x + 10.0; + let by = rect.min.y + 10.0; - // Separator + clip name (not clickable, it's the current level) - let rest_text = format!("{}{}", separator, clip_name); - ui.painter().text( - egui::pos2(scene_rect.max.x, breadcrumb_y + 4.0), - egui::Align2::LEFT_TOP, - rest_text, - font, - egui::Color32::WHITE, - ); + // Background pill sized to the whole path. + let full: String = segments + .iter() + .enumerate() + .map(|(i, (s, _))| if i == 0 { s.clone() } else { format!("{sep}{s}") }) + .collect(); + let full_galley = ui.painter().layout_no_wrap(full, font.clone(), egui::Color32::WHITE); + let pill = egui::Rect::from_min_size(egui::pos2(bx, by), full_galley.size() + egui::vec2(16.0, 8.0)); + ui.painter().rect_filled(pill, 4.0, egui::Color32::from_rgba_unmultiplied(0, 0, 0, 180)); + + // Draw segments left → right; clickable ancestors in blue. + let mut x = bx + 8.0; + let y = by + 4.0; + let mut clicked_target: Option = None; + for (i, (name, target)) in segments.iter().enumerate() { + if i > 0 { + let sg = ui.painter().layout_no_wrap(sep.to_string(), font.clone(), egui::Color32::from_gray(160)); + let sw = sg.size().x; + ui.painter().galley(egui::pos2(x, y), sg, egui::Color32::from_gray(160)); + x += sw; + } + let color = if target.is_some() { + egui::Color32::from_rgb(120, 180, 255) + } else { + egui::Color32::WHITE + }; + let g = ui.painter().layout_no_wrap(name.clone(), font.clone(), color); + let seg_rect = egui::Rect::from_min_size(egui::pos2(x, y), g.size()); + if let Some(depth) = target { + let resp = ui.allocate_rect(seg_rect, egui::Sense::click()); + if resp.clicked() { + clicked_target = Some(*depth); + } + if resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + } + ui.painter().galley(seg_rect.min, g, color); + x += seg_rect.width(); + } + if let Some(depth) = clicked_target { + *shared.pending_exit_to_depth = Some(depth); + } } // Render vector editing overlays (vertices, control points, etc.)