diff --git a/lightningbeam-ui/lightningbeam-core/src/dcel.rs b/lightningbeam-ui/lightningbeam-core/src/dcel.rs index 4b0a430..5224f75 100644 --- a/lightningbeam-ui/lightningbeam-core/src/dcel.rs +++ b/lightningbeam-ui/lightningbeam-core/src/dcel.rs @@ -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 diff --git a/lightningbeam-ui/lightningbeam-core/src/lib.rs b/lightningbeam-ui/lightningbeam-core/src/lib.rs index 70b8848..fc7a3cb 100644 --- a/lightningbeam-ui/lightningbeam-core/src/lib.rs +++ b/lightningbeam-ui/lightningbeam-core/src/lib.rs @@ -46,3 +46,6 @@ pub mod clipboard; pub mod region_select; pub mod dcel; pub mod snap; + +#[cfg(debug_assertions)] +pub mod test_mode; diff --git a/lightningbeam-ui/lightningbeam-core/src/test_mode.rs b/lightningbeam-ui/lightningbeam-core/src/test_mode.rs new file mode 100644 index 0000000..ae88c54 --- /dev/null +++ b/lightningbeam-ui/lightningbeam-core/src/test_mode.rs @@ -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 for SerPoint { + fn from(p: vello::kurbo::Point) -> Self { + Self { x: p.x, y: p.y } + } +} + +impl From for vello::kurbo::Point { + fn from(p: SerPoint) -> Self { + vello::kurbo::Point::new(p.x, p.y) + } +} + +impl From 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, + pub ended_with_panic: bool, + pub panic_message: Option, + pub panic_backtrace: Option, +} + +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 { + 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)) + } +} diff --git a/lightningbeam-ui/lightningbeam-editor/src/main.rs b/lightningbeam-ui/lightningbeam-editor/src/main.rs index 8f6752b..09876f8 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/main.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/main.rs @@ -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::Arc::new(std::sync::Mutex::new(None)); + #[cfg(debug_assertions)] + let test_mode_pending_event: std::sync::Arc>> = + std::sync::Arc::new(std::sync::Mutex::new(None)); + #[cfg(debug_assertions)] + let test_mode_is_replaying: std::sync::Arc = + 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::() { + 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, theme: Theme) -> Self { + fn new( + cc: &eframe::CreationContext, + layouts: Vec, + theme: Theme, + #[cfg(debug_assertions)] panic_snapshot: std::sync::Arc>>, + #[cfg(debug_assertions)] pending_event: std::sync::Arc>>, + #[cfg(debug_assertions)] is_replaying: std::sync::Arc, + ) -> 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 = 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_replay.synthetic_input; + // Queue for effect thumbnail requests (collected during rendering) let mut effect_thumbnail_requests: Vec = 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, /// 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, } /// 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) diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs index cb7bb07..e6d1a6b 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/mod.rs @@ -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, } /// Trait for pane rendering diff --git a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs index db2c292..4ad8361 100644 --- a/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs +++ b/lightningbeam-ui/lightningbeam-editor/src/panes/stage.rs @@ -2074,6 +2074,18 @@ pub struct StagePane { dcel_editing_cache: Option, // Current snap result (for visual feedback rendering) current_snap: Option, + /// Synthetic drag/click override for test mode replay (debug builds only) + #[cfg(debug_assertions)] + replay_override: Option, +} + +/// 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; } - let scroll_delta = ui.input(|i| i.smooth_scroll_delta); - 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); + // During replay with no synthetic event this frame, skip all input processing + #[cfg(debug_assertions)] + if is_replaying && !has_synthetic { + return; + } - // Get mouse position for zoom-to-cursor + 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()) + }; + + #[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,57 +6097,60 @@ impl StagePane { } } - // Distinguish between mouse wheel (discrete) and trackpad (smooth) - let mut handled = false; - ui.input(|i| { - for event in &i.raw.events { - if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { - match unit { - egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { - // Real mouse wheel (discrete clicks) -> always zoom - let zoom_delta = if ctrl_held || modifiers.ctrl { - delta.y * 0.01 // Ctrl+wheel: faster zoom - } else { - delta.y * 0.005 // Normal zoom - }; - self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos); - handled = true; - } - egui::MouseWheelUnit::Point => { - // Trackpad (smooth scrolling) -> only zoom if Ctrl held - if ctrl_held || modifiers.ctrl { - let zoom_delta = delta.y * 0.005; + // 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| { + for event in &i.raw.events { + if let egui::Event::MouseWheel { unit, delta, modifiers, .. } = event { + match unit { + egui::MouseWheelUnit::Line | egui::MouseWheelUnit::Page => { + // Real mouse wheel (discrete clicks) -> always zoom + let zoom_delta = if ctrl_held || modifiers.ctrl { + delta.y * 0.01 // Ctrl+wheel: faster zoom + } else { + delta.y * 0.005 // Normal zoom + }; self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos); handled = true; } - // Otherwise let scroll_delta handle panning + egui::MouseWheelUnit::Point => { + // Trackpad (smooth scrolling) -> only zoom if Ctrl held + if ctrl_held || modifiers.ctrl { + let zoom_delta = delta.y * 0.005; + self.apply_zoom_at_point(zoom_delta, mouse_canvas_pos); + handled = true; + } + // Otherwise let scroll_delta handle panning + } } } } + }); + + // Handle scroll_delta for trackpad panning (when Ctrl not held) + if !handled && (scroll_delta.x.abs() > 0.0 || scroll_delta.y.abs() > 0.0) { + self.pan_offset.x += scroll_delta.x; + self.pan_offset.y += scroll_delta.y; } - }); - // Handle scroll_delta for trackpad panning (when Ctrl not held) - if !handled && (scroll_delta.x.abs() > 0.0 || scroll_delta.y.abs() > 0.0) { - self.pan_offset.x += scroll_delta.x; - self.pan_offset.y += scroll_delta.y; - } - - // Handle panning with Alt+Drag - if alt_held && response.dragged() { - // Alt+Click+Drag panning - if let Some(last_pos) = self.last_pan_pos { - if let Some(current_pos) = response.interact_pointer_pos() { - let delta = current_pos - last_pos; - self.pan_offset += delta; + // Handle panning with Alt+Drag + if alt_held && response.dragged() { + // Alt+Click+Drag panning + if let Some(last_pos) = self.last_pan_pos { + if let Some(current_pos) = response.interact_pointer_pos() { + let delta = current_pos - last_pos; + self.pan_offset += delta; + } + } + self.last_pan_pos = response.interact_pointer_pos(); + self.is_panning = true; + } else { + if !response.dragged() { + self.is_panning = false; + self.last_pan_pos = None; } - } - self.last_pan_pos = response.interact_pointer_pos(); - self.is_panning = true; - } else { - if !response.dragged() { - self.is_panning = false; - self.last_pan_pos = None; } } } @@ -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()) { diff --git a/lightningbeam-ui/lightningbeam-editor/src/test_mode.rs b/lightningbeam-ui/lightningbeam-editor/src/test_mode.rs new file mode 100644 index 0000000..b5fc6ce --- /dev/null +++ b/lightningbeam-ui/lightningbeam-editor/src/test_mode.rs @@ -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, + /// 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, +} + +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, + /// 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>>, + /// 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>>, + /// Always-on ring buffer of last N events (for crash capture outside test mode) + pub event_ring: VecDeque, + 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, +} + +impl TestModeState { + pub fn new(panic_snapshot: Arc>>, pending_event: Arc>>, is_replaying: Arc) -> 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 { + 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>>, + pending_event: &Arc>>, + is_replaying: &Arc, + 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 = 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, + pub tool_change: Option, +} + +// ---- 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(" 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, 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 = 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 { + 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 { + 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 { + 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 { + 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) +}