Fix panic with vertex deduplication

This commit is contained in:
Skyler Lehmkuhl 2026-02-25 07:02:09 -05:00
parent 4c34c8a17d
commit 353aec3513
7 changed files with 1538 additions and 99 deletions

View File

@ -989,10 +989,33 @@ impl Dcel {
let t1 = hit.t1;
let t2 = hit.t2.unwrap_or(0.5);
// Check if intersection is close to a shared endpoint vertex.
// This handles edges that share a vertex and run nearly
// parallel near the junction — the intersection finder can
// report a hit a few pixels from the shared vertex.
// Skip crossings near a shared endpoint vertex. After
// splitting at a crossing, the sub-curves can still graze
// each other near the shared vertex. Detect this by
// checking whether the t-value on each edge places the
// hit near the endpoint that IS the shared vertex.
// Requires both edges to be near the shared vertex —
// a T-junction has the hit near the stem's endpoint but
// interior on the bar, so it won't be skipped.
let near_shared = shared.iter().any(|&sv| {
let a_near = if verts_a[0] == sv {
t1 < 0.05
} else {
t1 > 0.95
};
let b_near = if verts_b[0] == sv {
t2 < 0.05
} else {
t2 > 0.95
};
a_near && b_near
});
if near_shared {
continue;
}
// Also skip if spatially close to a shared vertex
// (catches cases where t-based check is borderline).
let close_to_shared = shared.iter().any(|&sv| {
let sv_pos = self.vertex(sv).position;
(hit.point - sv_pos).hypot() < 2.0
@ -2557,6 +2580,13 @@ impl Dcel {
v_keep: VertexId,
v_remove: VertexId,
) {
// If snap_vertex already merged these during split_edge, they're the
// same vertex. Proceeding would call free_vertex on a live vertex,
// putting it on the free list while edges still reference it.
if v_keep == v_remove {
return;
}
let keep_pos = self.vertices[v_keep.idx()].position;
// Re-home half-edges from v_remove → v_keep, and fix curve endpoints

View File

@ -46,3 +46,6 @@ pub mod clipboard;
pub mod region_select;
pub mod dcel;
pub mod snap;
#[cfg(debug_assertions)]
pub mod test_mode;

View File

@ -0,0 +1,124 @@
//! Debug test mode data types — input recording, panic capture & visual replay.
//!
//! All types are gated behind `#[cfg(debug_assertions)]` at the module level.
use serde::{Deserialize, Serialize};
use std::path::Path;
/// Serializable 2D point (avoids needing kurbo serde dependency)
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
pub struct SerPoint {
pub x: f64,
pub y: f64,
}
impl From<vello::kurbo::Point> for SerPoint {
fn from(p: vello::kurbo::Point) -> Self {
Self { x: p.x, y: p.y }
}
}
impl From<SerPoint> for vello::kurbo::Point {
fn from(p: SerPoint) -> Self {
vello::kurbo::Point::new(p.x, p.y)
}
}
impl From<egui::Vec2> for SerPoint {
fn from(v: egui::Vec2) -> Self {
Self {
x: v.x as f64,
y: v.y as f64,
}
}
}
/// Serializable modifier keys
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
pub struct SerModifiers {
pub ctrl: bool,
pub shift: bool,
pub alt: bool,
}
/// All recordable event types — recorded in clip-local document coordinates
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum TestEventKind {
MouseDown { pos: SerPoint },
MouseUp { pos: SerPoint },
MouseDrag { pos: SerPoint },
MouseMove { pos: SerPoint },
Scroll { delta_x: f32, delta_y: f32 },
KeyDown { key: String, modifiers: SerModifiers },
KeyUp { key: String, modifiers: SerModifiers },
ToolChanged { tool: String },
ActionExecuted { description: String },
}
/// A single timestamped event
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestEvent {
pub index: usize,
pub timestamp_ms: u64,
pub kind: TestEventKind,
}
/// Initial state snapshot for deterministic replay
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CanvasState {
pub zoom: f32,
pub pan_offset: (f32, f32),
pub selected_tool: String,
pub fill_color: [u8; 4],
pub stroke_color: [u8; 4],
pub stroke_width: f64,
pub fill_enabled: bool,
pub snap_enabled: bool,
pub polygon_sides: u32,
}
/// A complete test case (saved as pretty-printed JSON)
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TestCase {
pub name: String,
pub description: String,
pub recorded_at: String,
pub initial_canvas: CanvasState,
pub events: Vec<TestEvent>,
pub ended_with_panic: bool,
pub panic_message: Option<String>,
pub panic_backtrace: Option<String>,
}
impl TestCase {
/// Create a new empty test case with the given name and canvas state
pub fn new(name: String, initial_canvas: CanvasState) -> Self {
Self {
name,
description: String::new(),
recorded_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
initial_canvas,
events: Vec::new(),
ended_with_panic: false,
panic_message: None,
panic_backtrace: None,
}
}
/// Save to a JSON file
pub fn save_to_file(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string_pretty(self)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
std::fs::write(path, json)
}
/// Load from a JSON file
pub fn load_from_file(path: &Path) -> std::io::Result<Self> {
let json = std::fs::read_to_string(path)?;
serde_json::from_str(&json)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
}
}

View File

@ -41,6 +41,9 @@ use effect_thumbnails::EffectThumbnailGenerator;
mod custom_cursor;
mod debug_overlay;
#[cfg(debug_assertions)]
mod test_mode;
mod sample_import;
mod sample_import_dialog;
@ -175,10 +178,57 @@ fn main() -> eframe::Result {
..Default::default()
};
// Test mode: install panic hook for crash capture (debug builds only)
#[cfg(debug_assertions)]
let test_mode_panic_snapshot: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestCase>>> =
std::sync::Arc::new(std::sync::Mutex::new(None));
#[cfg(debug_assertions)]
let test_mode_pending_event: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestEvent>>> =
std::sync::Arc::new(std::sync::Mutex::new(None));
#[cfg(debug_assertions)]
let test_mode_is_replaying: std::sync::Arc<std::sync::atomic::AtomicBool> =
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(debug_assertions)]
let test_mode_panic_snapshot_for_app = test_mode_panic_snapshot.clone();
#[cfg(debug_assertions)]
let test_mode_pending_event_for_app = test_mode_pending_event.clone();
#[cfg(debug_assertions)]
let test_mode_is_replaying_for_app = test_mode_is_replaying.clone();
#[cfg(debug_assertions)]
{
let panic_snapshot = test_mode_panic_snapshot.clone();
let pending_event = test_mode_pending_event.clone();
let is_replaying = test_mode_is_replaying.clone();
let test_dir = directories::ProjectDirs::from("", "", "lightningbeam")
.map(|dirs| dirs.data_dir().join("test_cases"))
.unwrap_or_else(|| std::path::PathBuf::from("test_cases"));
let default_hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = info.payload().downcast_ref::<String>() {
s.clone()
} else {
format!("{}", info)
};
let backtrace = format!("{}", std::backtrace::Backtrace::force_capture());
test_mode::TestModeState::record_panic(&panic_snapshot, &pending_event, &is_replaying, msg, backtrace, &test_dir);
default_hook(info);
}));
}
eframe::run_native(
"Lightningbeam Editor",
options,
Box::new(move |cc| Ok(Box::new(EditorApp::new(cc, layouts, theme)))),
Box::new(move |cc| {
#[cfg(debug_assertions)]
let app = EditorApp::new(cc, layouts, theme, test_mode_panic_snapshot_for_app, test_mode_pending_event_for_app, test_mode_is_replaying_for_app);
#[cfg(not(debug_assertions))]
let app = EditorApp::new(cc, layouts, theme);
Ok(Box::new(app))
}),
)
}
@ -793,6 +843,10 @@ struct EditorApp {
/// Custom cursor cache for SVG cursors
cursor_cache: custom_cursor::CursorCache,
/// Debug test mode (F5) — input recording, panic capture & visual replay
#[cfg(debug_assertions)]
test_mode: test_mode::TestModeState,
/// Debug overlay (F3) state
debug_overlay_visible: bool,
debug_stats_collector: debug_overlay::DebugStatsCollector,
@ -817,7 +871,14 @@ enum ImportFilter {
}
impl EditorApp {
fn new(cc: &eframe::CreationContext, layouts: Vec<LayoutDefinition>, theme: Theme) -> Self {
fn new(
cc: &eframe::CreationContext,
layouts: Vec<LayoutDefinition>,
theme: Theme,
#[cfg(debug_assertions)] panic_snapshot: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestCase>>>,
#[cfg(debug_assertions)] pending_event: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestEvent>>>,
#[cfg(debug_assertions)] is_replaying: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Self {
let current_layout = layouts[0].layout.clone();
// Disable egui's "Unaligned" debug overlay (on by default in debug builds)
@ -992,6 +1053,10 @@ impl EditorApp {
export_orchestrator: None,
effect_thumbnail_generator: None, // Initialized when GPU available
// Debug test mode (F5)
#[cfg(debug_assertions)]
test_mode: test_mode::TestModeState::new(panic_snapshot, pending_event, is_replaying),
// Debug overlay (F3)
cursor_cache: custom_cursor::CursorCache::new(),
debug_overlay_visible: false,
@ -4653,6 +4718,17 @@ impl eframe::App for EditorApp {
return;
}
// Test mode sidebar (debug builds only) — must be before CentralPanel
#[cfg(debug_assertions)]
let test_mode_replay = test_mode::render_sidebar(ctx, &mut self.test_mode);
// Apply tool changes from replay
#[cfg(debug_assertions)]
if let Some(ref tool_name) = test_mode_replay.tool_change {
if let Some(tool) = test_mode::parse_tool(tool_name) {
self.selected_tool = tool;
}
}
// Main pane area (editor mode)
let mut layout_action: Option<LayoutAction> = None;
let mut clipboard_consumed = false;
@ -4678,6 +4754,10 @@ impl eframe::App for EditorApp {
let mut pending_enter_clip: Option<(Uuid, Uuid, Uuid)> = None;
let mut pending_exit_clip = false;
// Synthetic input from test mode replay (debug builds only)
#[cfg(debug_assertions)]
let mut synthetic_input_storage: Option<test_mode::SyntheticInput> = test_mode_replay.synthetic_input;
// Queue for effect thumbnail requests (collected during rendering)
let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new();
// Empty cache fallback if generator not initialized
@ -4779,6 +4859,10 @@ impl eframe::App for EditorApp {
region_select_mode: &mut self.region_select_mode,
pending_graph_loads: &self.pending_graph_loads,
clipboard_consumed: &mut clipboard_consumed,
#[cfg(debug_assertions)]
test_mode: &mut self.test_mode,
#[cfg(debug_assertions)]
synthetic_input: &mut synthetic_input_storage,
};
render_layout_node(
@ -4826,6 +4910,10 @@ impl eframe::App for EditorApp {
// Execute all pending actions (two-phase dispatch)
for action in pending_actions {
// Record action for test mode (debug builds only)
#[cfg(debug_assertions)]
let action_desc = action.description();
// Create backend context for actions that need backend sync
if let Some(ref controller_arc) = self.audio_controller {
let mut controller = controller_arc.lock().unwrap();
@ -4844,6 +4932,13 @@ impl eframe::App for EditorApp {
// No audio system available, execute without backend
let _ = self.action_executor.execute(action);
}
#[cfg(debug_assertions)]
self.test_mode.record_event(
lightningbeam_core::test_mode::TestEventKind::ActionExecuted {
description: action_desc,
},
);
}
// Process menu actions queued by pane context menus
@ -4993,6 +5088,23 @@ impl eframe::App for EditorApp {
}
});
// Record tool changes for test mode (debug builds only)
#[cfg(debug_assertions)]
{
// Use a simple static to track previous tool for change detection
use std::sync::atomic::{AtomicU8, Ordering};
static PREV_TOOL: AtomicU8 = AtomicU8::new(255);
let tool_byte = self.selected_tool as u8;
let prev = PREV_TOOL.swap(tool_byte, Ordering::Relaxed);
if prev != tool_byte && prev != 255 {
self.test_mode.record_event(
lightningbeam_core::test_mode::TestEventKind::ToolChanged {
tool: format!("{:?}", self.selected_tool),
},
);
}
}
// Escape key: revert uncommitted region selection
if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
if self.region_selection.is_some() {
@ -5009,6 +5121,15 @@ impl eframe::App for EditorApp {
self.debug_overlay_visible = !self.debug_overlay_visible;
}
// F5 test mode toggle (debug builds only)
#[cfg(debug_assertions)]
if ctx.input(|i| i.key_pressed(egui::Key::F5)) {
self.test_mode.active = !self.test_mode.active;
if self.test_mode.active {
self.test_mode.refresh_test_list();
}
}
// Clear the set of audio pools with new waveforms at the end of the frame
// (Thumbnails have been invalidated above, so this can be cleared for next frame)
if !self.audio_pools_with_new_waveforms.is_empty() {
@ -5121,6 +5242,12 @@ struct RenderContext<'a> {
pending_graph_loads: &'a std::sync::Arc<std::sync::atomic::AtomicU32>,
/// Set by panes when they handle Ctrl+C/X/V internally
clipboard_consumed: &'a mut bool,
/// Test mode state for event recording (debug builds only)
#[cfg(debug_assertions)]
test_mode: &'a mut test_mode::TestModeState,
/// Synthetic input from test mode replay (debug builds only)
#[cfg(debug_assertions)]
synthetic_input: &'a mut Option<test_mode::SyntheticInput>,
}
/// Recursively render a layout node with drag support
@ -5615,6 +5742,10 @@ fn render_pane(
editing_parent_layer_id: ctx.editing_parent_layer_id,
pending_enter_clip: ctx.pending_enter_clip,
pending_exit_clip: ctx.pending_exit_clip,
#[cfg(debug_assertions)]
test_mode: ctx.test_mode,
#[cfg(debug_assertions)]
synthetic_input: ctx.synthetic_input,
};
pane_instance.render_header(&mut header_ui, &mut shared);
}
@ -5698,6 +5829,10 @@ fn render_pane(
editing_parent_layer_id: ctx.editing_parent_layer_id,
pending_enter_clip: ctx.pending_enter_clip,
pending_exit_clip: ctx.pending_exit_clip,
#[cfg(debug_assertions)]
test_mode: ctx.test_mode,
#[cfg(debug_assertions)]
synthetic_input: ctx.synthetic_input,
};
// Render pane content (header was already rendered above)

View File

@ -246,6 +246,12 @@ pub struct SharedPaneState<'a> {
/// Set by panes (e.g. piano roll) when they handle Ctrl+C/X/V internally,
/// so main.rs skips its own clipboard handling for the current frame
pub clipboard_consumed: &'a mut bool,
/// Test mode state for event recording (debug builds only)
#[cfg(debug_assertions)]
pub test_mode: &'a mut crate::test_mode::TestModeState,
/// Synthetic input from test mode replay (debug builds only)
#[cfg(debug_assertions)]
pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>,
}
/// Trait for pane rendering

View File

@ -2074,6 +2074,18 @@ pub struct StagePane {
dcel_editing_cache: Option<DcelEditingCache>,
// Current snap result (for visual feedback rendering)
current_snap: Option<lightningbeam_core::snap::SnapResult>,
/// Synthetic drag/click override for test mode replay (debug builds only)
#[cfg(debug_assertions)]
replay_override: Option<ReplayDragState>,
}
/// Synthetic drag/click state injected during test mode replay
#[cfg(debug_assertions)]
#[derive(Clone, Copy)]
pub struct ReplayDragState {
pub drag_started: bool,
pub dragged: bool,
pub drag_stopped: bool,
}
/// Cached DCEL snapshot for undo when editing vertices, curves, or control points
@ -2136,9 +2148,64 @@ impl StagePane {
last_viewport_rect: None,
dcel_editing_cache: None,
current_snap: None,
#[cfg(debug_assertions)]
replay_override: None,
}
}
/// Check if a drag started, respecting replay override
fn rsp_drag_started(&self, response: &egui::Response) -> bool {
#[cfg(debug_assertions)]
if let Some(ref o) = self.replay_override { return o.drag_started; }
response.drag_started()
}
/// Check if dragging, respecting replay override
fn rsp_dragged(&self, response: &egui::Response) -> bool {
#[cfg(debug_assertions)]
if let Some(ref o) = self.replay_override { return o.dragged; }
response.dragged()
}
/// Check if drag stopped, respecting replay override
fn rsp_drag_stopped(&self, response: &egui::Response) -> bool {
#[cfg(debug_assertions)]
if let Some(ref o) = self.replay_override { return o.drag_stopped; }
response.drag_stopped()
}
/// Check if clicked (a click is a drag_started + drag_stopped in the same spot),
/// respecting replay override
fn rsp_clicked(&self, response: &egui::Response) -> bool {
#[cfg(debug_assertions)]
if let Some(ref o) = self.replay_override { return o.drag_started; }
response.clicked()
}
/// Check if primary mouse button was just pressed this frame,
/// respecting replay override
fn rsp_primary_pressed(&self, ui: &egui::Ui) -> bool {
#[cfg(debug_assertions)]
if let Some(ref o) = self.replay_override { return o.drag_started; }
ui.input(|i| i.pointer.primary_pressed())
}
/// Check if any pointer button was released this frame,
/// respecting replay override (returns the synthetic drag_stopped during replay)
fn rsp_any_released(&self, ui: &egui::Ui) -> bool {
#[cfg(debug_assertions)]
if let Some(ref o) = self.replay_override { return o.drag_stopped; }
ui.input(|i| i.pointer.any_released())
}
/// Check if primary pointer button is currently held down,
/// respecting replay override
fn rsp_primary_down(&self, ui: &egui::Ui) -> bool {
#[cfg(debug_assertions)]
if let Some(ref o) = self.replay_override { return o.dragged || o.drag_started; }
ui.input(|i| i.pointer.primary_down())
}
/// Convert a document-space position to clip-local coordinates when editing inside a clip.
/// Returns the position unchanged when at root level.
fn doc_to_clip_local(&self, doc_pos: egui::Vec2, shared: &SharedPaneState) -> egui::Vec2 {
@ -2330,7 +2397,7 @@ impl StagePane {
// Mouse down: start interaction (check on initial press, not after drag starts)
// Scope this section to drop vector_layer borrow before drag handling
let mouse_pressed = ui.input(|i| i.pointer.primary_pressed());
let mouse_pressed = self.rsp_primary_pressed(ui);
if mouse_pressed {
// VECTOR EDITING: Check for vertex/curve editing first (higher priority than selection)
let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64);
@ -2462,7 +2529,7 @@ impl StagePane {
}
// Mouse drag: update tool state
if response.dragged() {
if self.rsp_dragged(response) {
match shared.tool_state {
ToolState::PendingCurveInteraction { edge_id, parameter_t, start_mouse } => {
// Drag detected — transition to curve editing
@ -2492,8 +2559,8 @@ impl StagePane {
}
// Mouse up: finish interaction
let drag_stopped = response.drag_stopped();
let pointer_released = ui.input(|i| i.pointer.any_released());
let drag_stopped = self.rsp_drag_stopped(response);
let pointer_released = self.rsp_any_released(ui);
let is_pending_curve = matches!(shared.tool_state, ToolState::PendingCurveInteraction { .. });
let is_drag_or_marquee = matches!(shared.tool_state, ToolState::DraggingSelection { .. } | ToolState::MarqueeSelecting { .. });
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
@ -2941,7 +3008,7 @@ impl StagePane {
);
// Mouse down: start interaction (check on initial press, not after drag starts)
let mouse_pressed = ui.input(|i| i.pointer.primary_pressed());
let mouse_pressed = self.rsp_primary_pressed(ui);
if mouse_pressed {
// Priority 1: Vector editing (control points, vertices, and curves)
if let Some(hit) = vector_hit {
@ -2966,7 +3033,7 @@ impl StagePane {
}
// Mouse drag: update tool state
if response.dragged() {
if self.rsp_dragged(response) {
match shared.tool_state {
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
// Vector editing - update happens in helper method
@ -2977,8 +3044,8 @@ impl StagePane {
}
// Mouse up: finish interaction
let drag_stopped = response.drag_stopped();
let pointer_released = ui.input(|i| i.pointer.any_released());
let drag_stopped = self.rsp_drag_stopped(response);
let pointer_released = self.rsp_any_released(ui);
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
if drag_stopped || (pointer_released && is_vector_editing) {
@ -3104,7 +3171,7 @@ impl StagePane {
let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared);
// Mouse down: start creating rectangle (clears any previous preview)
if response.drag_started() || response.clicked() {
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
*shared.tool_state = ToolState::CreatingRectangle {
start_point: point,
current_point: point,
@ -3114,7 +3181,7 @@ impl StagePane {
}
// Mouse drag: update rectangle
if response.dragged() {
if self.rsp_dragged(response) {
if let ToolState::CreatingRectangle { start_point, .. } = shared.tool_state {
*shared.tool_state = ToolState::CreatingRectangle {
start_point: *start_point,
@ -3126,7 +3193,7 @@ impl StagePane {
}
// Mouse up: create the rectangle shape
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingRectangle { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::CreatingRectangle { .. })) {
if let ToolState::CreatingRectangle { start_point, current_point, centered, constrain_square } = shared.tool_state.clone() {
// Calculate rectangle bounds in world space
let (min_x, min_y, max_x, max_y) = if centered {
@ -3237,7 +3304,7 @@ impl StagePane {
let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared);
// Mouse down: start creating ellipse (clears any previous preview)
if response.drag_started() || response.clicked() {
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
*shared.tool_state = ToolState::CreatingEllipse {
start_point: point,
current_point: point,
@ -3247,7 +3314,7 @@ impl StagePane {
}
// Mouse drag: update ellipse
if response.dragged() {
if self.rsp_dragged(response) {
if let ToolState::CreatingEllipse { start_point, .. } = shared.tool_state {
*shared.tool_state = ToolState::CreatingEllipse {
start_point: *start_point,
@ -3259,7 +3326,7 @@ impl StagePane {
}
// Mouse up: create the ellipse shape
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingEllipse { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::CreatingEllipse { .. })) {
if let ToolState::CreatingEllipse { start_point, current_point, corner_mode, constrain_circle } = shared.tool_state.clone() {
// Calculate ellipse parameters based on mode
// Note: corner_mode is true when Ctrl is NOT held (inverted for consistency with rectangle)
@ -3361,7 +3428,7 @@ impl StagePane {
let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared);
// Mouse down: start creating line
if response.drag_started() || response.clicked() {
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
*shared.tool_state = ToolState::CreatingLine {
start_point: point,
current_point: point,
@ -3369,7 +3436,7 @@ impl StagePane {
}
// Mouse drag: update line
if response.dragged() {
if self.rsp_dragged(response) {
if let ToolState::CreatingLine { start_point, .. } = shared.tool_state {
*shared.tool_state = ToolState::CreatingLine {
start_point: *start_point,
@ -3379,7 +3446,7 @@ impl StagePane {
}
// Mouse up: create the line shape
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingLine { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::CreatingLine { .. })) {
if let ToolState::CreatingLine { start_point, current_point } = shared.tool_state.clone() {
// Calculate line length to ensure it's not too small
let dx = current_point.x - start_point.x;
@ -3443,7 +3510,7 @@ impl StagePane {
let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared);
// Mouse down: start creating polygon (center point)
if response.drag_started() || response.clicked() {
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
*shared.tool_state = ToolState::CreatingPolygon {
center: point,
current_point: point,
@ -3452,7 +3519,7 @@ impl StagePane {
}
// Mouse drag: update polygon radius
if response.dragged() {
if self.rsp_dragged(response) {
if let ToolState::CreatingPolygon { center, num_sides, .. } = shared.tool_state {
*shared.tool_state = ToolState::CreatingPolygon {
center: *center,
@ -3463,7 +3530,7 @@ impl StagePane {
}
// Mouse up: create the polygon shape
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::CreatingPolygon { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::CreatingPolygon { .. })) {
if let ToolState::CreatingPolygon { center, current_point, num_sides } = shared.tool_state.clone() {
// Calculate radius
let dx = current_point.x - center.x;
@ -3509,7 +3576,7 @@ impl StagePane {
shared: &mut SharedPaneState,
) {
// On click, store the screen position and color mode for sampling
if response.clicked() {
if self.rsp_clicked(response) {
self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode));
}
}
@ -3533,7 +3600,7 @@ impl StagePane {
};
// Mouse down: start region selection
if response.drag_started() {
if self.rsp_drag_started(response) {
// Revert any existing uncommitted region selection
Self::revert_region_selection_static(shared);
@ -3553,7 +3620,7 @@ impl StagePane {
}
// Mouse drag: update region
if response.dragged() {
if self.rsp_dragged(response) {
match shared.tool_state {
ToolState::RegionSelectingRect { ref start, .. } => {
let start = *start;
@ -3574,7 +3641,7 @@ impl StagePane {
}
// Mouse up: execute region selection
if response.drag_stopped() {
if self.rsp_drag_stopped(response) {
let region_path = match &*shared.tool_state {
ToolState::RegionSelectingRect { start, current } => {
let min_x = start.x.min(current.x);
@ -3817,7 +3884,7 @@ impl StagePane {
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
// Mouse down: start drawing path (snap the first point)
if response.drag_started() || response.clicked() {
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
let snapped_start = self.snap_point(point, shared);
*shared.tool_state = ToolState::DrawingPath {
points: vec![snapped_start],
@ -3826,7 +3893,7 @@ impl StagePane {
}
// Mouse drag: add points to path (no snapping for intermediate freehand points)
if response.dragged() {
if self.rsp_dragged(response) {
self.current_snap = None;
if let ToolState::DrawingPath { points, simplify_mode: _ } = &mut *shared.tool_state {
// Only add point if it's far enough from the last point (reduce noise)
@ -3844,7 +3911,7 @@ impl StagePane {
}
// Mouse up: snap the last point, then complete the path and create shape
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::DrawingPath { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::DrawingPath { .. })) {
// Snap the final point (extract last point first to avoid borrow conflict)
let last_point = if let ToolState::DrawingPath { points, .. } = &*shared.tool_state {
if points.len() >= 2 { Some(*points.last().unwrap()) } else { None }
@ -3958,7 +4025,7 @@ impl StagePane {
return;
}
if response.clicked() {
if self.rsp_clicked(response) {
let click_point = Point::new(world_pos.x as f64, world_pos.y as f64);
let fill_color = ShapeColor::from_egui(*shared.fill_color);
@ -4448,7 +4515,7 @@ impl StagePane {
match shared.tool_state.clone() {
ToolState::Transforming { mode, start_mouse, original_bbox, .. } => {
// Drag: apply transform preview to DCEL
if response.dragged() {
if self.rsp_dragged(response) {
*shared.tool_state = ToolState::Transforming {
mode: mode.clone(),
original_transforms: std::collections::HashMap::new(),
@ -4481,7 +4548,7 @@ impl StagePane {
}
// Release: finalize
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(*shared.tool_state, ToolState::Transforming { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(*shared.tool_state, ToolState::Transforming { .. })) {
if let Some(cache) = self.dcel_editing_cache.take() {
let dcel_after = {
let document = shared.action_executor.document();
@ -4507,7 +4574,7 @@ impl StagePane {
}
// Idle: check for handle clicks to start a transform
if response.drag_started() || response.clicked() {
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
let tolerance = 10.0;
if let Some(mode) = Self::hit_test_transform_handle(point, bbox, tolerance) {
// Snapshot DCEL for undo
@ -4796,7 +4863,7 @@ impl StagePane {
}
// Mouse down: check if clicking on a handle
if response.drag_started() || response.clicked() {
if self.rsp_drag_started(response) || self.rsp_clicked(response) {
let tolerance = 10.0; // Click tolerance in world space
if let Some(mode) = Self::hit_test_transform_handle(point, bbox, tolerance) {
@ -4833,7 +4900,7 @@ impl StagePane {
}
// Mouse drag: update current mouse position and apply transforms
if response.dragged() {
if self.rsp_dragged(response) {
if let ToolState::Transforming { mode, original_transforms, pivot, start_mouse, original_bbox, .. } = shared.tool_state.clone() {
// Update current mouse position
*shared.tool_state = ToolState::Transforming {
@ -4864,7 +4931,7 @@ impl StagePane {
}
// Mouse up: finalize transform
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::Transforming { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::Transforming { .. })) {
if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() {
use std::collections::HashMap;
use lightningbeam_core::actions::TransformClipInstancesAction;
@ -5098,8 +5165,8 @@ impl StagePane {
}
// === Mouse down: hit test handles (using the same handle positions and order as cursor logic) ===
let should_start_transform = (response.drag_started() || response.clicked())
|| (matches!(*shared.tool_state, ToolState::Idle) && ui.input(|i| i.pointer.primary_down()) && response.hovered());
let should_start_transform = (self.rsp_drag_started(response) || self.rsp_clicked(response))
|| (matches!(*shared.tool_state, ToolState::Idle) && self.rsp_primary_down(ui) && response.hovered());
if should_start_transform && matches!(*shared.tool_state, ToolState::Idle) {
// Check rotation handle (same as cursor logic)
@ -5245,7 +5312,7 @@ impl StagePane {
}
// Mouse drag: apply transform in local space
if response.dragged() {
if self.rsp_dragged(response) {
if let ToolState::Transforming { mode, original_transforms, start_mouse, current_mouse: _, .. } = shared.tool_state.clone() {
// Update current mouse
if let ToolState::Transforming { mode, original_transforms, pivot, start_mouse, original_bbox, current_mouse: _ } = shared.tool_state.clone() {
@ -5599,7 +5666,7 @@ impl StagePane {
}
// Mouse up: finalize
if response.drag_stopped() || (ui.input(|i| i.pointer.any_released()) && matches!(shared.tool_state, ToolState::Transforming { .. })) {
if self.rsp_drag_stopped(response) || (self.rsp_any_released(ui) && matches!(shared.tool_state, ToolState::Transforming { .. })) {
if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() {
use std::collections::HashMap;
use lightningbeam_core::actions::TransformClipInstancesAction;
@ -5681,7 +5748,36 @@ impl StagePane {
use lightningbeam_core::tool::ToolState;
use vello::kurbo::Point;
if ui.input(|i| i.pointer.any_released()) {
// When replaying, skip ALL real mouse/scroll input — only synthetic events drive state
#[cfg(debug_assertions)]
let is_replaying = matches!(shared.test_mode.mode, crate::test_mode::TestModeOp::Playing(_));
#[cfg(not(debug_assertions))]
let is_replaying = false;
// Store current input as a pending event for panic capture.
// If processing panics, the panic hook appends this to the saved test case.
#[cfg(debug_assertions)]
if !is_replaying {
if let Some(mouse_pos) = ui.input(|i| i.pointer.latest_pos()) {
use lightningbeam_core::test_mode::{SerPoint, TestEventKind};
let mouse_canvas_pos = mouse_pos - rect.min;
let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom;
let wp = self.doc_to_clip_local(world_pos_doc, shared);
let pos = SerPoint { x: wp.x as f64, y: wp.y as f64 };
let kind = if ui.input(|i| i.pointer.any_released()) {
TestEventKind::MouseUp { pos }
} else if ui.input(|i| i.pointer.primary_pressed()) && response.hovered() {
TestEventKind::MouseDown { pos }
} else if response.dragged() || response.drag_started() {
TestEventKind::MouseDrag { pos }
} else {
TestEventKind::MouseMove { pos }
};
shared.test_mode.set_pending_event(kind);
}
}
if !is_replaying && ui.input(|i| i.pointer.any_released()) {
match shared.tool_state.clone() {
ToolState::DraggingSelection { start_mouse, original_positions, .. } => {
// Get last known mouse position (will be at edge if offscreen)
@ -5795,26 +5891,98 @@ impl StagePane {
}
}
// Only process input if mouse is over the stage pane
if !response.hovered() {
// Check for synthetic input from test mode replay (debug builds only)
#[cfg(debug_assertions)]
let synthetic_input = shared.synthetic_input.take();
// Only process input if mouse is over the stage pane (or synthetic input is active)
#[cfg(debug_assertions)]
let has_synthetic = synthetic_input.is_some();
#[cfg(not(debug_assertions))]
let has_synthetic = false;
if !response.hovered() && !has_synthetic {
self.is_panning = false;
self.last_pan_pos = None;
return;
}
// During replay with no synthetic event this frame, skip all input processing
#[cfg(debug_assertions)]
if is_replaying && !has_synthetic {
return;
}
let scroll_delta = ui.input(|i| i.smooth_scroll_delta);
// Source input from synthetic (replay) or real UI
#[cfg(debug_assertions)]
let (world_pos, alt_held, ctrl_held, shift_held, drag_started, dragged, drag_stopped) = if let Some(syn) = &synthetic_input {
let wp = egui::Vec2::new(syn.world_pos.x as f32, syn.world_pos.y as f32);
(wp, syn.alt, syn.ctrl, syn.shift, syn.drag_started, syn.dragged, syn.drag_stopped)
} else {
let alt_held = ui.input(|i| i.modifiers.alt);
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
let shift_held = ui.input(|i| i.modifiers.shift);
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
let mouse_canvas_pos = mouse_pos - rect.min;
let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom;
let wp = self.doc_to_clip_local(world_pos_doc, shared);
(wp, alt_held, ctrl_held, shift_held, response.drag_started(), response.dragged(), response.drag_stopped())
};
// Get mouse position for zoom-to-cursor
#[cfg(not(debug_assertions))]
let (world_pos, alt_held, ctrl_held, shift_held, _drag_started, _dragged, _drag_stopped) = {
let alt_held = ui.input(|i| i.modifiers.alt);
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
let shift_held = ui.input(|i| i.modifiers.shift);
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
let mouse_canvas_pos = mouse_pos - rect.min;
let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom;
let wp = self.doc_to_clip_local(world_pos_doc, shared);
(wp, alt_held, ctrl_held, shift_held, response.drag_started(), response.dragged(), response.drag_stopped())
};
// Record mouse events for test mode (debug builds only) — skip during replay
//
// IMPORTANT: We use `primary_pressed` (fires immediately on button down) for MouseDown
// instead of `drag_started` (fires after egui's drag threshold, ~6-10px of movement).
// The select tool hit-tests on `primary_pressed`, so we must record the position at
// that moment. The `drag_started` frame is recorded as MouseDrag since the press
// was already captured.
#[cfg(debug_assertions)]
if !is_replaying {
use lightningbeam_core::test_mode::{SerPoint, TestEventKind};
let pos = SerPoint { x: world_pos.x as f64, y: world_pos.y as f64 };
let primary_just_pressed = response.hovered() && ui.input(|i| i.pointer.primary_pressed());
if primary_just_pressed {
shared.test_mode.record_event(TestEventKind::MouseDown { pos });
} else if drag_stopped {
// Emit a final MouseDrag at the release position to close the gap
// between the last drag frame and the release (the mouse moves between frames)
shared.test_mode.record_event(TestEventKind::MouseDrag { pos });
shared.test_mode.record_event(TestEventKind::MouseUp { pos });
} else if drag_started || dragged {
// drag_started after primary_pressed is just the first drag motion
shared.test_mode.record_event(TestEventKind::MouseDrag { pos });
} else if response.hovered() {
shared.test_mode.record_event(TestEventKind::MouseMove { pos });
}
}
// Get mouse position for zoom-to-cursor (needed for pan/zoom handling below)
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
let mouse_canvas_pos = mouse_pos - rect.min;
// Convert screen position to world position (accounting for pan and zoom)
// When inside a clip, further transform to clip-local coordinates
let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom;
let world_pos = self.doc_to_clip_local(world_pos_doc, shared);
// Set replay override so wrapper methods return synthetic drag state
#[cfg(debug_assertions)]
if synthetic_input.is_some() {
self.replay_override = Some(ReplayDragState {
drag_started,
dragged,
drag_stopped,
});
}
// Handle tool input (only if not using Alt modifier for panning)
if !alt_held {
@ -5860,6 +6028,10 @@ impl StagePane {
}
}
// Clear replay override after tool dispatch
#[cfg(debug_assertions)]
{ self.replay_override = None; }
// Delete/Backspace: remove selected DCEL elements
if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
if shared.selection.has_dcel_selection() {
@ -5925,6 +6097,8 @@ impl StagePane {
}
}
// Skip real scroll/zoom/pan input during replay
if !is_replaying {
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
let mut handled = false;
ui.input(|i| {
@ -5979,6 +6153,7 @@ impl StagePane {
}
}
}
}
/// Render vector editing overlays (vertices, control points, handles)
fn render_vector_editing_overlays(
@ -6326,7 +6501,7 @@ impl PaneRenderer for StagePane {
);
// Handle drop on mouse release
if ui.input(|i| i.pointer.any_released()) {
if self.rsp_any_released(ui) {
eprintln!("DEBUG STAGE DROP: Dropping clip type {:?}, linked_audio: {:?}",
dragging.clip_type, dragging.linked_audio_clip_id);
@ -6703,6 +6878,31 @@ impl PaneRenderer for StagePane {
// Render snap indicator (works for all tools, not just Select/BezierEdit)
self.render_snap_indicator(ui, rect, shared);
// Draw ghost cursor during test mode replay
#[cfg(debug_assertions)]
if let Some((wx, wy)) = shared.test_mode.replay_cursor_pos {
// Convert world-space position to screen-space
let screen_pos = rect.min + self.pan_offset + egui::vec2(wx as f32, wy as f32) * self.zoom;
let painter = ui.painter_at(rect);
// Crosshair
let arm = 10.0;
let stroke = egui::Stroke::new(1.5, egui::Color32::from_rgba_unmultiplied(255, 100, 100, 200));
painter.line_segment(
[screen_pos - egui::vec2(arm, 0.0), screen_pos + egui::vec2(arm, 0.0)],
stroke,
);
painter.line_segment(
[screen_pos - egui::vec2(0.0, arm), screen_pos + egui::vec2(0.0, arm)],
stroke,
);
// Circle
painter.circle_stroke(
screen_pos,
6.0,
egui::Stroke::new(1.5, egui::Color32::from_rgba_unmultiplied(255, 100, 100, 200)),
);
}
// Set custom tool cursor when pointer is over the stage canvas
// (system cursors from transform handles take priority via render_overlay check)
if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {

View File

@ -0,0 +1,941 @@
//! Debug test mode — input recording, panic capture, and visual replay.
//!
//! Gated behind `#[cfg(debug_assertions)]` at the module level in main.rs.
use eframe::egui;
use lightningbeam_core::test_mode::*;
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Instant;
use vello::kurbo::Point;
/// Maximum events kept in the always-on ring buffer for crash capture
const RING_BUFFER_SIZE: usize = 1000;
/// How often to snapshot state for the panic hook (every N events)
const PANIC_SNAPSHOT_INTERVAL: usize = 50;
// ---- Synthetic input for replay ----
/// Synthetic input data injected during replay, consumed by stage handle_input
pub struct SyntheticInput {
pub world_pos: Point,
pub drag_started: bool,
pub dragged: bool,
pub drag_stopped: bool,
#[allow(dead_code)] // Part of the synthetic input API, consumed when replay handles held-button state
pub primary_down: bool,
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
}
// ---- State machine ----
pub enum TestModeOp {
Idle,
Recording(TestRecorder),
Playing(TestPlayer),
}
pub struct TestRecorder {
pub test_case: TestCase,
pub start_time: Instant,
event_count: usize,
}
impl TestRecorder {
fn new(name: String, canvas_state: CanvasState) -> Self {
Self {
test_case: TestCase::new(name, canvas_state),
start_time: Instant::now(),
event_count: 0,
}
}
fn record(&mut self, kind: TestEventKind) {
let timestamp_ms = self.start_time.elapsed().as_millis() as u64;
let index = self.event_count;
self.event_count += 1;
self.test_case.events.push(TestEvent {
index,
timestamp_ms,
kind,
});
}
}
pub struct TestPlayer {
pub test_case: TestCase,
/// Next event index to execute
pub cursor: usize,
pub auto_playing: bool,
pub auto_play_delay_ms: u64,
pub last_step_time: Option<Instant>,
/// Collapse consecutive move/drag events in the event list and step through them as one batch
pub skip_consecutive_moves: bool,
/// When set, auto-play runs at max speed until cursor reaches this index, then stops
batch_end: Option<usize>,
}
impl TestPlayer {
fn new(test_case: TestCase) -> Self {
Self {
test_case,
cursor: 0,
auto_playing: false,
auto_play_delay_ms: 100,
last_step_time: None,
skip_consecutive_moves: true, // on by default
batch_end: None,
}
}
/// Advance cursor by one event and return it, or None if finished.
pub fn step_forward(&mut self) -> Option<&TestEvent> {
if self.cursor >= self.test_case.events.len() {
self.auto_playing = false;
self.batch_end = None;
return None;
}
let idx = self.cursor;
self.cursor += 1;
self.last_step_time = Some(Instant::now());
// Check if batch is done
if let Some(end) = self.batch_end {
if self.cursor >= end {
self.auto_playing = false;
self.batch_end = None;
}
}
Some(&self.test_case.events[idx])
}
/// Start a batch-replay of consecutive move/drag events from the current cursor.
/// Returns the first event in the batch, sets up auto-play for the rest.
pub fn step_or_batch(&mut self) -> Option<&TestEvent> {
if self.cursor >= self.test_case.events.len() {
return None;
}
if self.skip_consecutive_moves {
let disc = move_discriminant(&self.test_case.events[self.cursor].kind);
if let Some(d) = disc {
// Find end of consecutive run
let mut end = self.cursor + 1;
while end < self.test_case.events.len()
&& move_discriminant(&self.test_case.events[end].kind) == Some(d)
{
end += 1;
}
if end > self.cursor + 1 {
// Multi-event batch — auto-play through it at max speed
self.batch_end = Some(end);
self.auto_playing = true;
}
}
}
self.step_forward()
}
/// Whether auto-play should step this frame
pub fn should_auto_step(&self) -> bool {
if !self.auto_playing {
return false;
}
// Batch replay: every frame, no delay
if self.batch_end.is_some() {
return true;
}
// Normal auto-play: respect delay setting
match self.last_step_time {
None => true,
Some(t) => t.elapsed().as_millis() as u64 >= self.auto_play_delay_ms,
}
}
pub fn progress(&self) -> (usize, usize) {
(self.cursor, self.test_case.events.len())
}
pub fn reset(&mut self) {
self.cursor = 0;
self.auto_playing = false;
self.last_step_time = None;
self.batch_end = None;
}
}
// ---- Main state ----
pub struct TestModeState {
/// Whether the test mode sidebar is visible
pub active: bool,
/// Current operation
pub mode: TestModeOp,
/// Directory for test case files
pub test_dir: PathBuf,
/// List of available test files
pub available_tests: Vec<PathBuf>,
/// Name field for new recordings
pub new_test_name: String,
/// Transient status message
pub status_message: Option<(String, Instant)>,
/// Shared with panic hook — periodically updated with current state
pub panic_snapshot: Arc<Mutex<Option<TestCase>>>,
/// Current in-flight event, set before processing. If a panic occurs during
/// processing, the panic hook appends this to the saved test case.
pub pending_event: Arc<Mutex<Option<TestEvent>>>,
/// Always-on ring buffer of last N events (for crash capture outside test mode)
pub event_ring: VecDeque<TestEvent>,
pub ring_start_time: Instant,
ring_event_count: usize,
/// Counter since last panic snapshot update
events_since_snapshot: usize,
/// Last replayed world-space position (for ghost cursor rendering on stage)
pub replay_cursor_pos: Option<(f64, f64)>,
/// Shared with panic hook — when true, panics during replay don't save new crash files
pub is_replaying: Arc<AtomicBool>,
}
impl TestModeState {
pub fn new(panic_snapshot: Arc<Mutex<Option<TestCase>>>, pending_event: Arc<Mutex<Option<TestEvent>>>, is_replaying: Arc<AtomicBool>) -> Self {
let test_dir = directories::ProjectDirs::from("", "", "lightningbeam")
.map(|dirs| dirs.data_dir().join("test_cases"))
.unwrap_or_else(|| PathBuf::from("test_cases"));
Self {
active: false,
mode: TestModeOp::Idle,
test_dir,
available_tests: Vec::new(),
new_test_name: String::new(),
status_message: None,
panic_snapshot,
pending_event,
event_ring: VecDeque::with_capacity(RING_BUFFER_SIZE),
ring_start_time: Instant::now(),
ring_event_count: 0,
events_since_snapshot: 0,
replay_cursor_pos: None,
is_replaying,
}
}
/// Store the current in-flight event for panic capture.
/// Called before processing so the panic hook can grab it if processing panics.
pub fn set_pending_event(&self, kind: TestEventKind) {
let event = TestEvent {
index: self.ring_event_count,
timestamp_ms: self.ring_start_time.elapsed().as_millis() as u64,
kind,
};
if let Ok(mut guard) = self.pending_event.try_lock() {
*guard = Some(event);
}
}
/// Record an event — always appends to ring buffer, and to active recording if any
pub fn record_event(&mut self, kind: TestEventKind) {
let timestamp_ms = self.ring_start_time.elapsed().as_millis() as u64;
let index = self.ring_event_count;
self.ring_event_count += 1;
let event = TestEvent {
index,
timestamp_ms,
kind: kind.clone(),
};
// Always append to ring buffer
if self.event_ring.len() >= RING_BUFFER_SIZE {
self.event_ring.pop_front();
}
self.event_ring.push_back(event);
// Append to active recording if any
if let TestModeOp::Recording(ref mut recorder) = self.mode {
recorder.record(kind);
}
// Periodically update panic snapshot
self.events_since_snapshot += 1;
if self.events_since_snapshot >= PANIC_SNAPSHOT_INTERVAL {
self.events_since_snapshot = 0;
self.update_panic_snapshot();
}
}
/// Start a new recording
pub fn start_recording(&mut self, name: String, canvas_state: CanvasState) {
self.mode = TestModeOp::Recording(TestRecorder::new(name, canvas_state));
self.set_status("Recording started");
}
/// Stop recording and save to disk. Returns path if saved successfully.
pub fn stop_recording(&mut self) -> Option<PathBuf> {
let recorder = match std::mem::replace(&mut self.mode, TestModeOp::Idle) {
TestModeOp::Recording(r) => r,
other => {
self.mode = other;
return None;
}
};
let test_case = recorder.test_case;
let filename = sanitize_filename(&test_case.name);
let path = self.test_dir.join(format!("{}.json", filename));
match test_case.save_to_file(&path) {
Ok(()) => {
self.set_status(&format!("Saved: {}", path.display()));
self.refresh_test_list();
Some(path)
}
Err(e) => {
self.set_status(&format!("Save failed: {}", e));
None
}
}
}
/// Discard the current recording
pub fn discard_recording(&mut self) {
self.mode = TestModeOp::Idle;
self.set_status("Recording discarded");
}
/// Load a test case for playback
pub fn load_test(&mut self, path: &PathBuf) {
match TestCase::load_from_file(path) {
Ok(test_case) => {
self.set_status(&format!("Loaded: {} ({} events)", test_case.name, test_case.events.len()));
self.mode = TestModeOp::Playing(TestPlayer::new(test_case));
self.is_replaying.store(true, Ordering::SeqCst);
}
Err(e) => {
self.set_status(&format!("Load failed: {}", e));
}
}
}
/// Stop playback and return to idle
pub fn stop_playback(&mut self) {
self.mode = TestModeOp::Idle;
self.is_replaying.store(false, Ordering::SeqCst);
self.set_status("Playback stopped");
}
/// Called from panic hook — saves ring buffer or active recording as a crash test case.
/// Also grabs the pending in-flight event (if any) so the crash-triggering event is captured.
/// Skips saving when replaying a recorded test (to avoid duplicate crash files).
pub fn record_panic(
panic_snapshot: &Arc<Mutex<Option<TestCase>>>,
pending_event: &Arc<Mutex<Option<TestEvent>>>,
is_replaying: &Arc<AtomicBool>,
msg: String,
backtrace: String,
test_dir: &PathBuf,
) {
if is_replaying.load(Ordering::SeqCst) {
eprintln!("[TEST MODE] Panic during replay — not saving duplicate crash file");
return;
}
if let Ok(mut guard) = panic_snapshot.lock() {
let mut test_case = guard.take().unwrap_or_else(|| {
TestCase::new(
"crash_capture".to_string(),
CanvasState {
zoom: 1.0,
pan_offset: (0.0, 0.0),
selected_tool: "Unknown".to_string(),
fill_color: [0, 0, 0, 255],
stroke_color: [0, 0, 0, 255],
stroke_width: 3.0,
fill_enabled: true,
snap_enabled: true,
polygon_sides: 5,
},
)
});
// Append the in-flight event that was being processed when the panic occurred
if let Ok(mut pending) = pending_event.try_lock() {
if let Some(event) = pending.take() {
test_case.events.push(event);
}
}
test_case.ended_with_panic = true;
test_case.panic_message = Some(msg);
test_case.panic_backtrace = Some(backtrace);
let timestamp = format_timestamp();
let path = test_dir.join(format!("crash_{}.json", timestamp));
if let Err(e) = test_case.save_to_file(&path) {
eprintln!("[TEST MODE] Failed to save crash test case: {}", e);
} else {
eprintln!("[TEST MODE] Crash test case saved to: {}", path.display());
}
}
}
/// Refresh the list of available test files
pub fn refresh_test_list(&mut self) {
self.available_tests.clear();
if let Ok(entries) = std::fs::read_dir(&self.test_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "json") {
self.available_tests.push(path);
}
}
}
self.available_tests.sort();
}
fn set_status(&mut self, msg: &str) {
self.status_message = Some((msg.to_string(), Instant::now()));
}
/// Update the panic snapshot with current ring buffer state
fn update_panic_snapshot(&self) {
if let Ok(mut guard) = self.panic_snapshot.try_lock() {
let events: Vec<TestEvent> = self.event_ring.iter().cloned().collect();
let mut snapshot = TestCase::new(
"ring_buffer_snapshot".to_string(),
CanvasState {
zoom: 1.0,
pan_offset: (0.0, 0.0),
selected_tool: "Unknown".to_string(),
fill_color: [0, 0, 0, 255],
stroke_color: [0, 0, 0, 255],
stroke_width: 3.0,
fill_enabled: true,
snap_enabled: true,
polygon_sides: 5,
},
);
snapshot.events = events;
*guard = Some(snapshot);
}
}
}
// ---- Replay frame ----
/// Result of stepping a replay frame — carries both mouse input and non-mouse actions
#[derive(Default)]
pub struct ReplayFrame {
pub synthetic_input: Option<SyntheticInput>,
pub tool_change: Option<String>,
}
// ---- Sidebar UI ----
/// Render the test mode sidebar panel. Returns a ReplayFrame with actions to apply.
pub fn render_sidebar(
ctx: &egui::Context,
state: &mut TestModeState,
) -> ReplayFrame {
if !state.active {
return ReplayFrame::default();
}
let mut frame = ReplayFrame::default();
let mut action = SidebarAction::None;
egui::SidePanel::right("test_mode_panel")
.default_width(300.0)
.min_width(250.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("TEST MODE");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.button("X").clicked() {
state.active = false;
}
});
});
ui.separator();
// Status message (auto-clear after 5s)
if let Some((ref msg, when)) = state.status_message {
if when.elapsed().as_secs() < 5 {
ui.colored_label(egui::Color32::YELLOW, msg);
ui.separator();
} else {
state.status_message = None;
}
}
match &state.mode {
TestModeOp::Idle => {
render_idle_ui(ui, state, &mut action);
}
TestModeOp::Recording(_) => {
render_recording_ui(ui, state, &mut action);
}
TestModeOp::Playing(_) => {
frame = render_playing_ui(ui, state, &mut action);
}
}
});
// Execute deferred actions (avoid borrow conflicts)
match action {
SidebarAction::None => {}
SidebarAction::StartRecording(name, canvas) => {
state.start_recording(name, canvas);
}
SidebarAction::StopRecording => {
state.stop_recording();
}
SidebarAction::DiscardRecording => {
state.discard_recording();
}
SidebarAction::LoadTest(path) => {
state.load_test(&path);
}
SidebarAction::StopPlayback => {
state.stop_playback();
}
}
// Update ghost cursor position from the replay frame
if let Some(ref syn) = frame.synthetic_input {
state.replay_cursor_pos = Some((syn.world_pos.x, syn.world_pos.y));
} else if !matches!(state.mode, TestModeOp::Playing(_)) {
state.replay_cursor_pos = None;
}
frame
}
enum SidebarAction {
None,
StartRecording(String, CanvasState),
StopRecording,
DiscardRecording,
LoadTest(PathBuf),
StopPlayback,
}
fn render_idle_ui(ui: &mut egui::Ui, state: &mut TestModeState, action: &mut SidebarAction) {
ui.horizontal(|ui| {
if ui.button("Record").clicked() {
let name = if state.new_test_name.is_empty() {
format!("test_{}", format_timestamp())
} else {
state.new_test_name.clone()
};
// Default canvas state — will be overwritten by caller with real values
let canvas = CanvasState {
zoom: 1.0,
pan_offset: (0.0, 0.0),
selected_tool: "Select".to_string(),
fill_color: [100, 100, 255, 255],
stroke_color: [0, 0, 0, 255],
stroke_width: 3.0,
fill_enabled: true,
snap_enabled: true,
polygon_sides: 5,
};
*action = SidebarAction::StartRecording(name, canvas);
}
if ui.button("Load Test...").clicked() {
if let Some(path) = rfd::FileDialog::new()
.add_filter("JSON", &["json"])
.set_directory(&state.test_dir)
.pick_file()
{
*action = SidebarAction::LoadTest(path);
}
}
});
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.label("Name:");
ui.text_edit_singleline(&mut state.new_test_name);
});
// List available tests
ui.add_space(8.0);
ui.separator();
ui.label(egui::RichText::new("Saved Tests").strong());
if state.available_tests.is_empty() {
ui.colored_label(egui::Color32::GRAY, "(none)");
} else {
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
let mut load_path = None;
for path in &state.available_tests {
let name = path.file_name()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_default();
if ui.selectable_label(false, &name).clicked() {
load_path = Some(path.clone());
}
}
if let Some(path) = load_path {
*action = SidebarAction::LoadTest(path);
}
});
}
// Ring buffer info
ui.add_space(8.0);
ui.separator();
ui.colored_label(
egui::Color32::GRAY,
format!("Ring buffer: {} events (auto-saved on crash)", state.event_ring.len()),
);
}
fn render_recording_ui(ui: &mut egui::Ui, state: &mut TestModeState, action: &mut SidebarAction) {
let event_count = match &state.mode {
TestModeOp::Recording(r) => r.test_case.events.len(),
_ => 0,
};
ui.colored_label(egui::Color32::from_rgb(255, 80, 80),
format!("Recording... ({} events)", event_count));
ui.add_space(4.0);
ui.horizontal(|ui| {
if ui.button("Stop & Save").clicked() {
*action = SidebarAction::StopRecording;
}
if ui.button("Discard").clicked() {
*action = SidebarAction::DiscardRecording;
}
});
// Show recent events
if let TestModeOp::Recording(ref recorder) = state.mode {
render_event_list(ui, &recorder.test_case.events, None, false);
}
}
fn render_playing_ui(
ui: &mut egui::Ui,
state: &mut TestModeState,
action: &mut SidebarAction,
) -> ReplayFrame {
let mut frame = ReplayFrame::default();
let (cursor, total, test_name, has_panic, panic_msg, auto_playing, delay_ms) = match &state.mode {
TestModeOp::Playing(p) => {
let (c, t) = p.progress();
(
c, t,
p.test_case.name.clone(),
p.test_case.ended_with_panic,
p.test_case.panic_message.clone(),
p.auto_playing,
p.auto_play_delay_ms,
)
}
_ => return ReplayFrame::default(),
};
ui.label(format!("Test: {}", test_name));
if has_panic {
ui.colored_label(egui::Color32::RED, "Ended with PANIC");
if let Some(ref msg) = panic_msg {
let display_msg = if msg.len() > 120 { &msg[..120] } else { msg.as_str() };
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), display_msg);
}
}
ui.label(format!("{}/{} events", cursor, total));
// Transport controls
ui.horizontal(|ui| {
// Reset
if ui.button("|<").clicked() {
if let TestModeOp::Playing(ref mut p) = state.mode {
p.reset();
}
}
// Step back (reset to cursor - 1)
if ui.button("<Step").clicked() {
if let TestModeOp::Playing(ref mut p) = state.mode {
if p.cursor > 0 {
p.cursor -= 1;
}
}
}
// Step forward (batches consecutive move/drag events when skip is on)
if ui.button("Step>").clicked() {
if let TestModeOp::Playing(ref mut p) = state.mode {
if let Some(event) = p.step_or_batch() {
frame = event_to_replay_frame(event);
}
}
}
// Auto-play toggle
let auto_label = if auto_playing { "||Pause" } else { ">>Auto" };
if ui.button(auto_label).clicked() {
if let TestModeOp::Playing(ref mut p) = state.mode {
p.auto_playing = !p.auto_playing;
}
}
// Stop
if ui.button("Stop").clicked() {
*action = SidebarAction::StopPlayback;
}
});
// Speed slider
ui.horizontal(|ui| {
ui.label("Speed:");
let mut delay = delay_ms as f32;
if ui.add(egui::Slider::new(&mut delay, 10.0..=500.0).suffix("ms")).changed() {
if let TestModeOp::Playing(ref mut p) = state.mode {
p.auto_play_delay_ms = delay as u64;
}
}
});
// Skip consecutive moves toggle
if let TestModeOp::Playing(ref mut p) = state.mode {
ui.checkbox(&mut p.skip_consecutive_moves, "Skip consecutive moves");
}
// Auto-step
if auto_playing {
if let TestModeOp::Playing(ref mut p) = state.mode {
if p.should_auto_step() {
if let Some(event) = p.step_forward() {
frame = event_to_replay_frame(event);
}
}
}
// Request continuous repaint during auto-play
ui.ctx().request_repaint();
}
// Event list
if let TestModeOp::Playing(ref player) = state.mode {
render_event_list(ui, &player.test_case.events, Some(player.cursor), player.skip_consecutive_moves);
}
frame
}
fn render_event_list(ui: &mut egui::Ui, events: &[TestEvent], cursor: Option<usize>, skip_moves: bool) {
ui.add_space(8.0);
ui.separator();
ui.label(egui::RichText::new("Events").strong());
// Build filtered index list when skip_moves is on
let filtered: Vec<usize> = if skip_moves {
filter_consecutive_moves(events)
} else {
(0..events.len()).collect()
};
egui::ScrollArea::vertical()
.auto_shrink([false; 2])
.max_height(ui.available_height() - 20.0)
.show(ui, |ui| {
// Find the cursor position within the filtered list
let cursor_filtered_pos = cursor.and_then(|c| {
// Find the filtered entry closest to (but not past) cursor
filtered.iter().rposition(|&idx| idx < c)
});
let focus = cursor_filtered_pos.unwrap_or(filtered.len().saturating_sub(1));
let start = focus.saturating_sub(50);
let end = (focus + 50).min(filtered.len());
for &event_idx in &filtered[start..end] {
let event = &events[event_idx];
let is_current = cursor.map_or(false, |c| event.index == c.saturating_sub(1));
let (prefix, color) = event_display_info(&event.kind, is_current);
let text = format!("{} #{} {}", prefix, event.index, format_event_kind(&event.kind));
let label = egui::RichText::new(text).color(color).monospace();
ui.label(label);
}
});
}
/// Filter event indices, keeping only the last of each consecutive run of same-type moves/drags
fn filter_consecutive_moves(events: &[TestEvent]) -> Vec<usize> {
let mut result = Vec::with_capacity(events.len());
let mut i = 0;
while i < events.len() {
let disc = move_discriminant(&events[i].kind);
if let Some(d) = disc {
// Scan ahead to find the last in this consecutive run
let mut last = i;
while last + 1 < events.len() && move_discriminant(&events[last + 1].kind) == Some(d) {
last += 1;
}
result.push(last);
i = last + 1;
} else {
result.push(i);
i += 1;
}
}
result
}
fn event_display_info(kind: &TestEventKind, is_current: bool) -> (&'static str, egui::Color32) {
let prefix = if is_current { ">" } else { " " };
let color = match kind {
TestEventKind::MouseMove { .. } | TestEventKind::MouseDrag { .. } => {
egui::Color32::from_gray(140)
}
TestEventKind::MouseDown { .. } | TestEventKind::MouseUp { .. } => {
egui::Color32::from_gray(200)
}
TestEventKind::ToolChanged { .. } => egui::Color32::from_rgb(100, 150, 255),
TestEventKind::ActionExecuted { .. } => egui::Color32::from_rgb(100, 255, 100),
TestEventKind::KeyDown { .. } | TestEventKind::KeyUp { .. } => {
egui::Color32::from_rgb(200, 200, 100)
}
TestEventKind::Scroll { .. } => egui::Color32::from_gray(160),
};
(prefix, color)
}
fn format_event_kind(kind: &TestEventKind) -> String {
match kind {
TestEventKind::MouseDown { pos } => format!("MouseDown ({:.1}, {:.1})", pos.x, pos.y),
TestEventKind::MouseUp { pos } => format!("MouseUp ({:.1}, {:.1})", pos.x, pos.y),
TestEventKind::MouseDrag { pos } => format!("MouseDrag ({:.1}, {:.1})", pos.x, pos.y),
TestEventKind::MouseMove { pos } => format!("MouseMove ({:.1}, {:.1})", pos.x, pos.y),
TestEventKind::Scroll { delta_x, delta_y } => {
format!("Scroll ({:.1}, {:.1})", delta_x, delta_y)
}
TestEventKind::KeyDown { key, .. } => format!("KeyDown {}", key),
TestEventKind::KeyUp { key, .. } => format!("KeyUp {}", key),
TestEventKind::ToolChanged { tool } => format!("ToolChanged: {}", tool),
TestEventKind::ActionExecuted { description } => {
format!("ActionExecuted: \"{}\"", description)
}
}
}
/// Convert a replayed TestEvent into a ReplayFrame carrying mouse input and/or tool changes
fn event_to_replay_frame(event: &TestEvent) -> ReplayFrame {
let mut frame = ReplayFrame::default();
match &event.kind {
TestEventKind::ToolChanged { tool } => {
frame.tool_change = Some(tool.clone());
}
other => {
frame.synthetic_input = event_kind_to_synthetic(other);
}
}
frame
}
/// Convert a mouse event kind into a SyntheticInput for the stage pane
fn event_kind_to_synthetic(kind: &TestEventKind) -> Option<SyntheticInput> {
match kind {
TestEventKind::MouseDown { pos } => Some(SyntheticInput {
world_pos: Point::new(pos.x, pos.y),
drag_started: true,
dragged: true, // In egui, drag_started() implies dragged() on the same frame
drag_stopped: false,
primary_down: true,
shift: false,
ctrl: false,
alt: false,
}),
TestEventKind::MouseDrag { pos } => Some(SyntheticInput {
world_pos: Point::new(pos.x, pos.y),
drag_started: false,
dragged: true,
drag_stopped: false,
primary_down: true,
shift: false,
ctrl: false,
alt: false,
}),
TestEventKind::MouseUp { pos } => Some(SyntheticInput {
world_pos: Point::new(pos.x, pos.y),
drag_started: false,
dragged: true, // In egui, dragged() is still true on the release frame
drag_stopped: true,
primary_down: false,
shift: false,
ctrl: false,
alt: false,
}),
TestEventKind::MouseMove { pos } => Some(SyntheticInput {
world_pos: Point::new(pos.x, pos.y),
drag_started: false,
dragged: false,
drag_stopped: false,
primary_down: false,
shift: false,
ctrl: false,
alt: false,
}),
// Non-mouse events don't produce synthetic input (handled elsewhere)
_ => None,
}
}
/// Returns a discriminant for "batchable" mouse motion event types.
/// Same-discriminant events are collapsed in the event list display
/// and replayed as a single batch when stepping.
/// Returns None for non-batchable events (clicks, tool changes, actions, etc.)
fn move_discriminant(kind: &TestEventKind) -> Option<u8> {
match kind {
TestEventKind::MouseMove { .. } => Some(0),
TestEventKind::MouseDrag { .. } => Some(1),
_ => None,
}
}
/// Sanitize a string for use as a filename
fn sanitize_filename(name: &str) -> String {
name.chars()
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
.collect()
}
/// Parse a tool name (from Debug format) back into a Tool enum value
pub fn parse_tool(name: &str) -> Option<lightningbeam_core::tool::Tool> {
use lightningbeam_core::tool::Tool;
match name {
"Select" => Some(Tool::Select),
"Draw" => Some(Tool::Draw),
"Transform" => Some(Tool::Transform),
"Rectangle" => Some(Tool::Rectangle),
"Ellipse" => Some(Tool::Ellipse),
"PaintBucket" => Some(Tool::PaintBucket),
"Eyedropper" => Some(Tool::Eyedropper),
"Line" => Some(Tool::Line),
"Polygon" => Some(Tool::Polygon),
"BezierEdit" => Some(Tool::BezierEdit),
"Text" => Some(Tool::Text),
"RegionSelect" => Some(Tool::RegionSelect),
_ => None,
}
}
/// Format current time as a compact timestamp (no chrono dependency in editor crate)
fn format_timestamp() -> String {
use std::time::SystemTime;
let duration = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
let secs = duration.as_secs();
// Simple but unique timestamp: seconds since epoch
// For human-readable format we'd need chrono, but this is fine for filenames
format!("{}", secs)
}