968 lines
33 KiB
Rust
968 lines
33 KiB
Rust
//! 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>>>,
|
|
/// Geometry context set before risky operations (e.g. region select).
|
|
/// Shared with the panic hook so crashes include the relevant geometry.
|
|
pub pending_geometry: Arc<Mutex<Option<serde_json::Value>>>,
|
|
/// 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>, pending_geometry: Arc<Mutex<Option<serde_json::Value>>>) -> 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,
|
|
pending_geometry,
|
|
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 geometry context for panic capture.
|
|
/// Called before risky operations (e.g. region select) so the panic hook
|
|
/// can include it in the crash file for easier reproduction.
|
|
pub fn set_pending_geometry(&self, context: serde_json::Value) {
|
|
if let Ok(mut guard) = self.pending_geometry.try_lock() {
|
|
*guard = Some(context);
|
|
}
|
|
}
|
|
|
|
/// Clear the pending geometry context (call after the operation succeeds).
|
|
pub fn clear_pending_geometry(&self) {
|
|
if let Ok(mut guard) = self.pending_geometry.try_lock() {
|
|
*guard = None;
|
|
}
|
|
}
|
|
|
|
/// 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>,
|
|
pending_geometry: &Arc<Mutex<Option<serde_json::Value>>>,
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Attach geometry context if one was set before the crash
|
|
if let Ok(mut geom_guard) = pending_geometry.try_lock() {
|
|
test_case.geometry_context = geom_guard.take();
|
|
}
|
|
|
|
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)
|
|
}
|