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:
parent
01b20165cc
commit
4f58edf436
|
|
@ -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<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 {
|
||||
|
|
@ -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<panes::ViewActionHandler>,
|
||||
pending_enter_clip: Option<(Uuid, Uuid, Uuid)>,
|
||||
pending_exit_clip: bool,
|
||||
pending_exit_to_depth: Option<usize>,
|
||||
pending_actions: Vec<Box<dyn lightningbeam_core::action::Action>>,
|
||||
pending_menu_actions: Vec<MenuAction>,
|
||||
effect_thumbnail_requests: Vec<Uuid>,
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,10 +212,15 @@ pub struct SharedPaneState<'a> {
|
|||
pub editing_instance_id: Option<uuid::Uuid>,
|
||||
/// The parent layer ID containing the clip instance being edited
|
||||
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)
|
||||
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<usize>,
|
||||
/// Currently active layer ID
|
||||
pub active_layer_id: &'a mut Option<uuid::Uuid>,
|
||||
/// Current tool interaction state (mutable for tools to modify)
|
||||
|
|
|
|||
|
|
@ -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<usize>)> = {
|
||||
let document = shared.action_executor.document();
|
||||
let n = shared.editing_clip_path.len();
|
||||
let mut segs: Vec<(String, Option<usize>)> = 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<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.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.)
|
||||
|
|
|
|||
Loading…
Reference in New Issue