P5 follow-ups: double-tap priority, inspector dismiss, breadcrumb path

- 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.
This commit is contained in:
Skyler Lehmkuhl 2026-07-02 01:14:34 -04:00
parent 01b20165cc
commit 4f58edf436
5 changed files with 163 additions and 70 deletions

View File

@ -942,6 +942,21 @@ impl EditingContext {
self.stack.pop() self.stack.pop()
} }
/// The clip_id path from outermost to the current level (for breadcrumbs).
fn clip_path(&self) -> Vec<Uuid> {
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<EditingContextEntry> {
if depth >= self.stack.len() {
return None;
}
let restore = self.stack[depth].clone();
self.stack.truncate(depth);
Some(restore)
}
} }
struct EditorApp { 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 // Set cursor based on hover state
if let Some((_, is_horizontal)) = self.hovered_divider { if let Some((_, is_horizontal)) = self.hovered_divider {
if is_horizontal { if is_horizontal {
@ -7157,6 +7181,7 @@ struct FrameScratch {
pending_handlers: Vec<panes::ViewActionHandler>, pending_handlers: Vec<panes::ViewActionHandler>,
pending_enter_clip: Option<(Uuid, Uuid, Uuid)>, pending_enter_clip: Option<(Uuid, Uuid, Uuid)>,
pending_exit_clip: bool, pending_exit_clip: bool,
pending_exit_to_depth: Option<usize>,
pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>>, pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>>,
pending_menu_actions: Vec<MenuAction>, pending_menu_actions: Vec<MenuAction>,
effect_thumbnail_requests: Vec<Uuid>, effect_thumbnail_requests: Vec<Uuid>,
@ -7221,8 +7246,10 @@ impl EditorApp {
editing_clip_id: self.editing_context.current_clip_id(), editing_clip_id: self.editing_context.current_clip_id(),
editing_instance_id: self.editing_context.current_instance_id(), editing_instance_id: self.editing_context.current_instance_id(),
editing_parent_layer_id: self.editing_context.current_parent_layer_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_enter_clip: &mut scratch.pending_enter_clip,
pending_exit_clip: &mut scratch.pending_exit_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, active_layer_id: &mut self.active_layer_id,
tool_state: &mut self.tool_state, tool_state: &mut self.tool_state,
pending_actions: &mut scratch.pending_actions, pending_actions: &mut scratch.pending_actions,

View File

@ -22,6 +22,22 @@ pub fn is_active(shared: &SharedPaneState) -> bool {
!shared.focus.is_none() || !shared.selection.is_empty() !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 /// 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 #[allow(dead_code)] // selection→pane mapping; kept for reflow/jump heuristics

View File

@ -197,6 +197,11 @@ pub struct MobileState {
/// Screen-y of the tap that opened the inspector — used to decide whether the tapped thing would /// 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). /// be hidden by the sheet (only then do we reflow the stack).
pub inspector_anchor_y: f32, 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. /// 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.
@ -225,6 +230,8 @@ impl Default for MobileState {
inspector_frac: 0.45, inspector_frac: 0.45,
inspector_visible: false, inspector_visible: false,
inspector_anchor_y: 0.0, inspector_anchor_y: 0.0,
inspector_dismissed: false,
inspector_sel_sig: 0,
omni_open: false, omni_open: false,
omni_grid_open: false, omni_grid_open: false,
omni_create_open: false, omni_create_open: false,
@ -269,9 +276,16 @@ pub fn render_mobile_shell(
// interacted with (pointer down but still selected). // interacted with (pointer down but still selected).
let active = inspector::is_active(&rc.shared); let active = inspector::is_active(&rc.shared);
let any_down = ui.input(|i| i.pointer.any_down()); 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 { if !active {
state.inspector_visible = false; state.inspector_visible = false;
} else if !any_down { state.inspector_dismissed = false;
} else if !state.inspector_dismissed && !any_down {
if !state.inspector_visible { if !state.inspector_visible {
// Just opened (tap released): anchor to the release position for the reflow test below. // Just opened (tap released): anchor to the release position for the reflow test below.
state.inspector_anchor_y = ui state.inspector_anchor_y = ui
@ -289,8 +303,9 @@ pub fn render_mobile_shell(
}; };
let mut sheet_top = region.bottom() - sheet_h; let mut sheet_top = region.bottom() - sheet_h;
// Tapping outside the sheet (but not on the transport) dismisses the inspector. The press still // Tapping outside the sheet (but not on the transport) dismisses (hides) the inspector. We only
// reaches the panes below via stack::render, so tapping another object reselects and reopens it. // 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 { if inspector_shown {
let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max); let sheet_rect = egui::Rect::from_min_max(egui::pos2(region.left(), sheet_top), region.max);
let dismiss = ui.input(|i| { let dismiss = ui.input(|i| {
@ -300,9 +315,8 @@ pub fn render_mobile_shell(
}) })
}); });
if dismiss { if dismiss {
rc.shared.selection.clear();
*rc.shared.focus = lightningbeam_core::selection::FocusSelection::None;
state.inspector_visible = false; state.inspector_visible = false;
state.inspector_dismissed = true;
inspector_shown = false; inspector_shown = false;
sheet_top = region.bottom(); sheet_top = region.bottom();
} }

View File

@ -212,10 +212,15 @@ pub struct SharedPaneState<'a> {
pub editing_instance_id: Option<uuid::Uuid>, pub editing_instance_id: Option<uuid::Uuid>,
/// The parent layer ID containing the clip instance being edited /// The parent layer ID containing the clip instance being edited
pub editing_parent_layer_id: Option<uuid::Uuid>, pub editing_parent_layer_id: Option<uuid::Uuid>,
/// The full clip_id path being edited, outermost → current (for breadcrumbs). Empty at root.
pub editing_clip_path: Vec<uuid::Uuid>,
/// Request to enter a movie clip for editing: (clip_id, instance_id, parent_layer_id) /// 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)>, pub pending_enter_clip: &'a mut Option<(uuid::Uuid, uuid::Uuid, uuid::Uuid)>,
/// Request to exit the current movie clip /// Request to exit the current movie clip
pub pending_exit_clip: &'a mut bool, 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<usize>,
/// Currently active layer ID /// Currently active layer ID
pub active_layer_id: &'a mut Option<uuid::Uuid>, pub active_layer_id: &'a mut Option<uuid::Uuid>,
/// Current tool interaction state (mutable for tools to modify) /// Current tool interaction state (mutable for tools to modify)

View File

@ -4009,8 +4009,9 @@ impl StagePane {
// geometry selection/editing there; clip-instance interaction stays available. // geometry selection/editing there; clip-instance interaction stays available.
let inbetween = vector_layer.is_tween_inbetween(*shared.playback_time); let inbetween = vector_layer.is_tween_inbetween(*shared.playback_time);
// Double-click: enter/exit movie clip editing // Double-click: enter/exit movie clip editing (desktop; mobile handles double-tap as a
if response.double_clicked() { // single tool-independent gesture in handle_input).
if !shared.is_mobile && response.double_clicked() {
// Hit test clip instances at the click position // Hit test clip instances at the click position
let document = shared.action_executor.document(); let document = shared.action_executor.document();
let clip_hit = hit_test::hit_test_clip_instances( 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 // 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 // `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 // 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 // Render breadcrumb navigation showing the full editing path (root → … → current clip).
let info_color = shared.theme.text_color(&["#stage", ".text-secondary"], ui.ctx(), egui::Color32::from_gray(200)); if !shared.editing_clip_path.is_empty() {
ui.painter().text( let font = egui::FontId::proportional(13.0);
rect.min + egui::vec2(10.0, 10.0), let sep = " > ";
egui::Align2::LEFT_TOP, // Segments: "Scene 1" (root) + each clip name. `target` = depth to exit to when clicked
format!("Vello Stage (zoom: {:.2}, pan: {:.0},{:.0})", // (root → 0; clip at path index k → k+1); the current (last) level isn't clickable.
self.zoom, self.pan_offset.x, self.pan_offset.y), let segments: Vec<(String, Option<usize>)> = {
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(); let document = shared.action_executor.document();
// Build breadcrumb names from the editing context let n = shared.editing_clip_path.len();
// We only have the current clip_id, so show "Scene 1 > ClipName" let mut segs: Vec<(String, Option<usize>)> = vec![("Scene 1".to_string(), Some(0))];
let clip_name = shared.editing_clip_id for (k, cid) in shared.editing_clip_path.iter().enumerate() {
.and_then(|id| document.get_vector_clip(&id)) let name = document
.get_vector_clip(cid)
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.unwrap_or_else(|| "Unknown".to_string()); .unwrap_or_else(|| "Unknown".to_string());
segs.push((name, if k + 1 == n { None } else { Some(k + 1) }));
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);
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),
);
// "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() { segs
};
let bx = rect.min.x + 10.0;
let by = rect.min.y + 10.0;
// 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<usize> = 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.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
} }
}
// Separator + clip name (not clickable, it's the current level) ui.painter().galley(seg_rect.min, g, color);
let rest_text = format!("{}{}", separator, clip_name); x += seg_rect.width();
ui.painter().text( }
egui::pos2(scene_rect.max.x, breadcrumb_y + 4.0), if let Some(depth) = clicked_target {
egui::Align2::LEFT_TOP, *shared.pending_exit_to_depth = Some(depth);
rest_text, }
font,
egui::Color32::WHITE,
);
} }
// Render vector editing overlays (vertices, control points, etc.) // Render vector editing overlays (vertices, control points, etc.)