Fix panic with vertex deduplication
This commit is contained in:
parent
4c34c8a17d
commit
353aec3513
|
|
@ -989,10 +989,33 @@ impl Dcel {
|
||||||
let t1 = hit.t1;
|
let t1 = hit.t1;
|
||||||
let t2 = hit.t2.unwrap_or(0.5);
|
let t2 = hit.t2.unwrap_or(0.5);
|
||||||
|
|
||||||
// Check if intersection is close to a shared endpoint vertex.
|
// Skip crossings near a shared endpoint vertex. After
|
||||||
// This handles edges that share a vertex and run nearly
|
// splitting at a crossing, the sub-curves can still graze
|
||||||
// parallel near the junction — the intersection finder can
|
// each other near the shared vertex. Detect this by
|
||||||
// report a hit a few pixels from the shared vertex.
|
// 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 close_to_shared = shared.iter().any(|&sv| {
|
||||||
let sv_pos = self.vertex(sv).position;
|
let sv_pos = self.vertex(sv).position;
|
||||||
(hit.point - sv_pos).hypot() < 2.0
|
(hit.point - sv_pos).hypot() < 2.0
|
||||||
|
|
@ -2557,6 +2580,13 @@ impl Dcel {
|
||||||
v_keep: VertexId,
|
v_keep: VertexId,
|
||||||
v_remove: 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;
|
let keep_pos = self.vertices[v_keep.idx()].position;
|
||||||
|
|
||||||
// Re-home half-edges from v_remove → v_keep, and fix curve endpoints
|
// Re-home half-edges from v_remove → v_keep, and fix curve endpoints
|
||||||
|
|
|
||||||
|
|
@ -46,3 +46,6 @@ pub mod clipboard;
|
||||||
pub mod region_select;
|
pub mod region_select;
|
||||||
pub mod dcel;
|
pub mod dcel;
|
||||||
pub mod snap;
|
pub mod snap;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub mod test_mode;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
//! Debug test mode data types — input recording, panic capture & visual replay.
|
||||||
|
//!
|
||||||
|
//! All types are gated behind `#[cfg(debug_assertions)]` at the module level.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Serializable 2D point (avoids needing kurbo serde dependency)
|
||||||
|
#[derive(Clone, Copy, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SerPoint {
|
||||||
|
pub x: f64,
|
||||||
|
pub y: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<vello::kurbo::Point> for SerPoint {
|
||||||
|
fn from(p: vello::kurbo::Point) -> Self {
|
||||||
|
Self { x: p.x, y: p.y }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<SerPoint> for vello::kurbo::Point {
|
||||||
|
fn from(p: SerPoint) -> Self {
|
||||||
|
vello::kurbo::Point::new(p.x, p.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<egui::Vec2> for SerPoint {
|
||||||
|
fn from(v: egui::Vec2) -> Self {
|
||||||
|
Self {
|
||||||
|
x: v.x as f64,
|
||||||
|
y: v.y as f64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serializable modifier keys
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize)]
|
||||||
|
pub struct SerModifiers {
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub shift: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All recordable event types — recorded in clip-local document coordinates
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub enum TestEventKind {
|
||||||
|
MouseDown { pos: SerPoint },
|
||||||
|
MouseUp { pos: SerPoint },
|
||||||
|
MouseDrag { pos: SerPoint },
|
||||||
|
MouseMove { pos: SerPoint },
|
||||||
|
Scroll { delta_x: f32, delta_y: f32 },
|
||||||
|
KeyDown { key: String, modifiers: SerModifiers },
|
||||||
|
KeyUp { key: String, modifiers: SerModifiers },
|
||||||
|
ToolChanged { tool: String },
|
||||||
|
ActionExecuted { description: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A single timestamped event
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TestEvent {
|
||||||
|
pub index: usize,
|
||||||
|
pub timestamp_ms: u64,
|
||||||
|
pub kind: TestEventKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initial state snapshot for deterministic replay
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct CanvasState {
|
||||||
|
pub zoom: f32,
|
||||||
|
pub pan_offset: (f32, f32),
|
||||||
|
pub selected_tool: String,
|
||||||
|
pub fill_color: [u8; 4],
|
||||||
|
pub stroke_color: [u8; 4],
|
||||||
|
pub stroke_width: f64,
|
||||||
|
pub fill_enabled: bool,
|
||||||
|
pub snap_enabled: bool,
|
||||||
|
pub polygon_sides: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A complete test case (saved as pretty-printed JSON)
|
||||||
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TestCase {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub recorded_at: String,
|
||||||
|
pub initial_canvas: CanvasState,
|
||||||
|
pub events: Vec<TestEvent>,
|
||||||
|
pub ended_with_panic: bool,
|
||||||
|
pub panic_message: Option<String>,
|
||||||
|
pub panic_backtrace: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestCase {
|
||||||
|
/// Create a new empty test case with the given name and canvas state
|
||||||
|
pub fn new(name: String, initial_canvas: CanvasState) -> Self {
|
||||||
|
Self {
|
||||||
|
name,
|
||||||
|
description: String::new(),
|
||||||
|
recorded_at: chrono::Local::now().format("%Y-%m-%dT%H:%M:%S").to_string(),
|
||||||
|
initial_canvas,
|
||||||
|
events: Vec::new(),
|
||||||
|
ended_with_panic: false,
|
||||||
|
panic_message: None,
|
||||||
|
panic_backtrace: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save to a JSON file
|
||||||
|
pub fn save_to_file(&self, path: &Path) -> std::io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(self)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?;
|
||||||
|
std::fs::write(path, json)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load from a JSON file
|
||||||
|
pub fn load_from_file(path: &Path) -> std::io::Result<Self> {
|
||||||
|
let json = std::fs::read_to_string(path)?;
|
||||||
|
serde_json::from_str(&json)
|
||||||
|
.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,9 @@ use effect_thumbnails::EffectThumbnailGenerator;
|
||||||
mod custom_cursor;
|
mod custom_cursor;
|
||||||
mod debug_overlay;
|
mod debug_overlay;
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
mod test_mode;
|
||||||
|
|
||||||
mod sample_import;
|
mod sample_import;
|
||||||
mod sample_import_dialog;
|
mod sample_import_dialog;
|
||||||
|
|
||||||
|
|
@ -175,10 +178,57 @@ fn main() -> eframe::Result {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Test mode: install panic hook for crash capture (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let test_mode_panic_snapshot: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestCase>>> =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let test_mode_pending_event: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestEvent>>> =
|
||||||
|
std::sync::Arc::new(std::sync::Mutex::new(None));
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let test_mode_is_replaying: std::sync::Arc<std::sync::atomic::AtomicBool> =
|
||||||
|
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let test_mode_panic_snapshot_for_app = test_mode_panic_snapshot.clone();
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let test_mode_pending_event_for_app = test_mode_pending_event.clone();
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let test_mode_is_replaying_for_app = test_mode_is_replaying.clone();
|
||||||
|
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
{
|
||||||
|
let panic_snapshot = test_mode_panic_snapshot.clone();
|
||||||
|
let pending_event = test_mode_pending_event.clone();
|
||||||
|
let is_replaying = test_mode_is_replaying.clone();
|
||||||
|
let test_dir = directories::ProjectDirs::from("", "", "lightningbeam")
|
||||||
|
.map(|dirs| dirs.data_dir().join("test_cases"))
|
||||||
|
.unwrap_or_else(|| std::path::PathBuf::from("test_cases"));
|
||||||
|
|
||||||
|
let default_hook = std::panic::take_hook();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let msg = if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||||
|
s.to_string()
|
||||||
|
} else if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||||
|
s.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}", info)
|
||||||
|
};
|
||||||
|
let backtrace = format!("{}", std::backtrace::Backtrace::force_capture());
|
||||||
|
test_mode::TestModeState::record_panic(&panic_snapshot, &pending_event, &is_replaying, msg, backtrace, &test_dir);
|
||||||
|
default_hook(info);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
eframe::run_native(
|
eframe::run_native(
|
||||||
"Lightningbeam Editor",
|
"Lightningbeam Editor",
|
||||||
options,
|
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
|
/// Custom cursor cache for SVG cursors
|
||||||
cursor_cache: custom_cursor::CursorCache,
|
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 (F3) state
|
||||||
debug_overlay_visible: bool,
|
debug_overlay_visible: bool,
|
||||||
debug_stats_collector: debug_overlay::DebugStatsCollector,
|
debug_stats_collector: debug_overlay::DebugStatsCollector,
|
||||||
|
|
@ -817,7 +871,14 @@ enum ImportFilter {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl EditorApp {
|
impl EditorApp {
|
||||||
fn new(cc: &eframe::CreationContext, layouts: Vec<LayoutDefinition>, theme: Theme) -> Self {
|
fn new(
|
||||||
|
cc: &eframe::CreationContext,
|
||||||
|
layouts: Vec<LayoutDefinition>,
|
||||||
|
theme: Theme,
|
||||||
|
#[cfg(debug_assertions)] panic_snapshot: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestCase>>>,
|
||||||
|
#[cfg(debug_assertions)] pending_event: std::sync::Arc<std::sync::Mutex<Option<lightningbeam_core::test_mode::TestEvent>>>,
|
||||||
|
#[cfg(debug_assertions)] is_replaying: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||||
|
) -> Self {
|
||||||
let current_layout = layouts[0].layout.clone();
|
let current_layout = layouts[0].layout.clone();
|
||||||
|
|
||||||
// Disable egui's "Unaligned" debug overlay (on by default in debug builds)
|
// Disable egui's "Unaligned" debug overlay (on by default in debug builds)
|
||||||
|
|
@ -992,6 +1053,10 @@ impl EditorApp {
|
||||||
export_orchestrator: None,
|
export_orchestrator: None,
|
||||||
effect_thumbnail_generator: None, // Initialized when GPU available
|
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)
|
// Debug overlay (F3)
|
||||||
cursor_cache: custom_cursor::CursorCache::new(),
|
cursor_cache: custom_cursor::CursorCache::new(),
|
||||||
debug_overlay_visible: false,
|
debug_overlay_visible: false,
|
||||||
|
|
@ -4653,6 +4718,17 @@ impl eframe::App for EditorApp {
|
||||||
return;
|
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)
|
// Main pane area (editor mode)
|
||||||
let mut layout_action: Option<LayoutAction> = None;
|
let mut layout_action: Option<LayoutAction> = None;
|
||||||
let mut clipboard_consumed = false;
|
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_enter_clip: Option<(Uuid, Uuid, Uuid)> = None;
|
||||||
let mut pending_exit_clip = false;
|
let mut pending_exit_clip = false;
|
||||||
|
|
||||||
|
// Synthetic input from test mode replay (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
let mut synthetic_input_storage: Option<test_mode::SyntheticInput> = test_mode_replay.synthetic_input;
|
||||||
|
|
||||||
// Queue for effect thumbnail requests (collected during rendering)
|
// Queue for effect thumbnail requests (collected during rendering)
|
||||||
let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new();
|
let mut effect_thumbnail_requests: Vec<Uuid> = Vec::new();
|
||||||
// Empty cache fallback if generator not initialized
|
// Empty cache fallback if generator not initialized
|
||||||
|
|
@ -4779,6 +4859,10 @@ impl eframe::App for EditorApp {
|
||||||
region_select_mode: &mut self.region_select_mode,
|
region_select_mode: &mut self.region_select_mode,
|
||||||
pending_graph_loads: &self.pending_graph_loads,
|
pending_graph_loads: &self.pending_graph_loads,
|
||||||
clipboard_consumed: &mut clipboard_consumed,
|
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(
|
render_layout_node(
|
||||||
|
|
@ -4826,6 +4910,10 @@ impl eframe::App for EditorApp {
|
||||||
|
|
||||||
// Execute all pending actions (two-phase dispatch)
|
// Execute all pending actions (two-phase dispatch)
|
||||||
for action in pending_actions {
|
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
|
// Create backend context for actions that need backend sync
|
||||||
if let Some(ref controller_arc) = self.audio_controller {
|
if let Some(ref controller_arc) = self.audio_controller {
|
||||||
let mut controller = controller_arc.lock().unwrap();
|
let mut controller = controller_arc.lock().unwrap();
|
||||||
|
|
@ -4844,6 +4932,13 @@ impl eframe::App for EditorApp {
|
||||||
// No audio system available, execute without backend
|
// No audio system available, execute without backend
|
||||||
let _ = self.action_executor.execute(action);
|
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
|
// 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
|
// Escape key: revert uncommitted region selection
|
||||||
if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
if !wants_keyboard && ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
|
||||||
if self.region_selection.is_some() {
|
if self.region_selection.is_some() {
|
||||||
|
|
@ -5009,6 +5121,15 @@ impl eframe::App for EditorApp {
|
||||||
self.debug_overlay_visible = !self.debug_overlay_visible;
|
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
|
// 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)
|
// (Thumbnails have been invalidated above, so this can be cleared for next frame)
|
||||||
if !self.audio_pools_with_new_waveforms.is_empty() {
|
if !self.audio_pools_with_new_waveforms.is_empty() {
|
||||||
|
|
@ -5121,6 +5242,12 @@ struct RenderContext<'a> {
|
||||||
pending_graph_loads: &'a std::sync::Arc<std::sync::atomic::AtomicU32>,
|
pending_graph_loads: &'a std::sync::Arc<std::sync::atomic::AtomicU32>,
|
||||||
/// Set by panes when they handle Ctrl+C/X/V internally
|
/// Set by panes when they handle Ctrl+C/X/V internally
|
||||||
clipboard_consumed: &'a mut bool,
|
clipboard_consumed: &'a mut bool,
|
||||||
|
/// Test mode state for event recording (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
test_mode: &'a mut test_mode::TestModeState,
|
||||||
|
/// Synthetic input from test mode replay (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
synthetic_input: &'a mut Option<test_mode::SyntheticInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Recursively render a layout node with drag support
|
/// Recursively render a layout node with drag support
|
||||||
|
|
@ -5615,6 +5742,10 @@ fn render_pane(
|
||||||
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
||||||
pending_enter_clip: ctx.pending_enter_clip,
|
pending_enter_clip: ctx.pending_enter_clip,
|
||||||
pending_exit_clip: ctx.pending_exit_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);
|
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,
|
editing_parent_layer_id: ctx.editing_parent_layer_id,
|
||||||
pending_enter_clip: ctx.pending_enter_clip,
|
pending_enter_clip: ctx.pending_enter_clip,
|
||||||
pending_exit_clip: ctx.pending_exit_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)
|
// Render pane content (header was already rendered above)
|
||||||
|
|
|
||||||
|
|
@ -246,6 +246,12 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Set by panes (e.g. piano roll) when they handle Ctrl+C/X/V internally,
|
/// 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
|
/// so main.rs skips its own clipboard handling for the current frame
|
||||||
pub clipboard_consumed: &'a mut bool,
|
pub clipboard_consumed: &'a mut bool,
|
||||||
|
/// Test mode state for event recording (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub test_mode: &'a mut crate::test_mode::TestModeState,
|
||||||
|
/// Synthetic input from test mode replay (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
pub synthetic_input: &'a mut Option<crate::test_mode::SyntheticInput>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Trait for pane rendering
|
/// Trait for pane rendering
|
||||||
|
|
|
||||||
|
|
@ -2074,6 +2074,18 @@ pub struct StagePane {
|
||||||
dcel_editing_cache: Option<DcelEditingCache>,
|
dcel_editing_cache: Option<DcelEditingCache>,
|
||||||
// Current snap result (for visual feedback rendering)
|
// Current snap result (for visual feedback rendering)
|
||||||
current_snap: Option<lightningbeam_core::snap::SnapResult>,
|
current_snap: Option<lightningbeam_core::snap::SnapResult>,
|
||||||
|
/// Synthetic drag/click override for test mode replay (debug builds only)
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
replay_override: Option<ReplayDragState>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Synthetic drag/click state injected during test mode replay
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
#[derive(Clone, Copy)]
|
||||||
|
pub struct ReplayDragState {
|
||||||
|
pub drag_started: bool,
|
||||||
|
pub dragged: bool,
|
||||||
|
pub drag_stopped: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Cached DCEL snapshot for undo when editing vertices, curves, or control points
|
/// Cached DCEL snapshot for undo when editing vertices, curves, or control points
|
||||||
|
|
@ -2136,9 +2148,64 @@ impl StagePane {
|
||||||
last_viewport_rect: None,
|
last_viewport_rect: None,
|
||||||
dcel_editing_cache: None,
|
dcel_editing_cache: None,
|
||||||
current_snap: 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.
|
/// Convert a document-space position to clip-local coordinates when editing inside a clip.
|
||||||
/// Returns the position unchanged when at root level.
|
/// Returns the position unchanged when at root level.
|
||||||
fn doc_to_clip_local(&self, doc_pos: egui::Vec2, shared: &SharedPaneState) -> egui::Vec2 {
|
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)
|
// Mouse down: start interaction (check on initial press, not after drag starts)
|
||||||
// Scope this section to drop vector_layer borrow before drag handling
|
// 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 {
|
if mouse_pressed {
|
||||||
// VECTOR EDITING: Check for vertex/curve editing first (higher priority than selection)
|
// VECTOR EDITING: Check for vertex/curve editing first (higher priority than selection)
|
||||||
let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64);
|
let tolerance = EditingHitTolerance::scaled_by_zoom(self.zoom as f64);
|
||||||
|
|
@ -2462,7 +2529,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: update tool state
|
// Mouse drag: update tool state
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
match shared.tool_state {
|
match shared.tool_state {
|
||||||
ToolState::PendingCurveInteraction { edge_id, parameter_t, start_mouse } => {
|
ToolState::PendingCurveInteraction { edge_id, parameter_t, start_mouse } => {
|
||||||
// Drag detected — transition to curve editing
|
// Drag detected — transition to curve editing
|
||||||
|
|
@ -2492,8 +2559,8 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: finish interaction
|
// Mouse up: finish interaction
|
||||||
let drag_stopped = response.drag_stopped();
|
let drag_stopped = self.rsp_drag_stopped(response);
|
||||||
let pointer_released = ui.input(|i| i.pointer.any_released());
|
let pointer_released = self.rsp_any_released(ui);
|
||||||
let is_pending_curve = matches!(shared.tool_state, ToolState::PendingCurveInteraction { .. });
|
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_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 { .. });
|
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)
|
// 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 {
|
if mouse_pressed {
|
||||||
// Priority 1: Vector editing (control points, vertices, and curves)
|
// Priority 1: Vector editing (control points, vertices, and curves)
|
||||||
if let Some(hit) = vector_hit {
|
if let Some(hit) = vector_hit {
|
||||||
|
|
@ -2966,7 +3033,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: update tool state
|
// Mouse drag: update tool state
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
match shared.tool_state {
|
match shared.tool_state {
|
||||||
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
|
ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. } => {
|
||||||
// Vector editing - update happens in helper method
|
// Vector editing - update happens in helper method
|
||||||
|
|
@ -2977,8 +3044,8 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: finish interaction
|
// Mouse up: finish interaction
|
||||||
let drag_stopped = response.drag_stopped();
|
let drag_stopped = self.rsp_drag_stopped(response);
|
||||||
let pointer_released = ui.input(|i| i.pointer.any_released());
|
let pointer_released = self.rsp_any_released(ui);
|
||||||
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
|
let is_vector_editing = matches!(shared.tool_state, ToolState::EditingVertex { .. } | ToolState::EditingCurve { .. } | ToolState::EditingControlPoint { .. });
|
||||||
|
|
||||||
if drag_stopped || (pointer_released && is_vector_editing) {
|
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);
|
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)
|
// 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 {
|
*shared.tool_state = ToolState::CreatingRectangle {
|
||||||
start_point: point,
|
start_point: point,
|
||||||
current_point: point,
|
current_point: point,
|
||||||
|
|
@ -3114,7 +3181,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: update rectangle
|
// Mouse drag: update rectangle
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
if let ToolState::CreatingRectangle { start_point, .. } = shared.tool_state {
|
if let ToolState::CreatingRectangle { start_point, .. } = shared.tool_state {
|
||||||
*shared.tool_state = ToolState::CreatingRectangle {
|
*shared.tool_state = ToolState::CreatingRectangle {
|
||||||
start_point: *start_point,
|
start_point: *start_point,
|
||||||
|
|
@ -3126,7 +3193,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: create the rectangle shape
|
// 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() {
|
if let ToolState::CreatingRectangle { start_point, current_point, centered, constrain_square } = shared.tool_state.clone() {
|
||||||
// Calculate rectangle bounds in world space
|
// Calculate rectangle bounds in world space
|
||||||
let (min_x, min_y, max_x, max_y) = if centered {
|
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);
|
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)
|
// 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 {
|
*shared.tool_state = ToolState::CreatingEllipse {
|
||||||
start_point: point,
|
start_point: point,
|
||||||
current_point: point,
|
current_point: point,
|
||||||
|
|
@ -3247,7 +3314,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: update ellipse
|
// Mouse drag: update ellipse
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
if let ToolState::CreatingEllipse { start_point, .. } = shared.tool_state {
|
if let ToolState::CreatingEllipse { start_point, .. } = shared.tool_state {
|
||||||
*shared.tool_state = ToolState::CreatingEllipse {
|
*shared.tool_state = ToolState::CreatingEllipse {
|
||||||
start_point: *start_point,
|
start_point: *start_point,
|
||||||
|
|
@ -3259,7 +3326,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: create the ellipse shape
|
// 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() {
|
if let ToolState::CreatingEllipse { start_point, current_point, corner_mode, constrain_circle } = shared.tool_state.clone() {
|
||||||
// Calculate ellipse parameters based on mode
|
// Calculate ellipse parameters based on mode
|
||||||
// Note: corner_mode is true when Ctrl is NOT held (inverted for consistency with rectangle)
|
// 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);
|
let point = self.snap_point(Point::new(world_pos.x as f64, world_pos.y as f64), shared);
|
||||||
|
|
||||||
// Mouse down: start creating line
|
// 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 {
|
*shared.tool_state = ToolState::CreatingLine {
|
||||||
start_point: point,
|
start_point: point,
|
||||||
current_point: point,
|
current_point: point,
|
||||||
|
|
@ -3369,7 +3436,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: update line
|
// Mouse drag: update line
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
if let ToolState::CreatingLine { start_point, .. } = shared.tool_state {
|
if let ToolState::CreatingLine { start_point, .. } = shared.tool_state {
|
||||||
*shared.tool_state = ToolState::CreatingLine {
|
*shared.tool_state = ToolState::CreatingLine {
|
||||||
start_point: *start_point,
|
start_point: *start_point,
|
||||||
|
|
@ -3379,7 +3446,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: create the line shape
|
// 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() {
|
if let ToolState::CreatingLine { start_point, current_point } = shared.tool_state.clone() {
|
||||||
// Calculate line length to ensure it's not too small
|
// Calculate line length to ensure it's not too small
|
||||||
let dx = current_point.x - start_point.x;
|
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);
|
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)
|
// 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 {
|
*shared.tool_state = ToolState::CreatingPolygon {
|
||||||
center: point,
|
center: point,
|
||||||
current_point: point,
|
current_point: point,
|
||||||
|
|
@ -3452,7 +3519,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: update polygon radius
|
// Mouse drag: update polygon radius
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
if let ToolState::CreatingPolygon { center, num_sides, .. } = shared.tool_state {
|
if let ToolState::CreatingPolygon { center, num_sides, .. } = shared.tool_state {
|
||||||
*shared.tool_state = ToolState::CreatingPolygon {
|
*shared.tool_state = ToolState::CreatingPolygon {
|
||||||
center: *center,
|
center: *center,
|
||||||
|
|
@ -3463,7 +3530,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: create the polygon shape
|
// 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() {
|
if let ToolState::CreatingPolygon { center, current_point, num_sides } = shared.tool_state.clone() {
|
||||||
// Calculate radius
|
// Calculate radius
|
||||||
let dx = current_point.x - center.x;
|
let dx = current_point.x - center.x;
|
||||||
|
|
@ -3509,7 +3576,7 @@ impl StagePane {
|
||||||
shared: &mut SharedPaneState,
|
shared: &mut SharedPaneState,
|
||||||
) {
|
) {
|
||||||
// On click, store the screen position and color mode for sampling
|
// 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));
|
self.pending_eyedropper_sample = Some((screen_pos, *shared.active_color_mode));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3533,7 +3600,7 @@ impl StagePane {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mouse down: start region selection
|
// Mouse down: start region selection
|
||||||
if response.drag_started() {
|
if self.rsp_drag_started(response) {
|
||||||
// Revert any existing uncommitted region selection
|
// Revert any existing uncommitted region selection
|
||||||
Self::revert_region_selection_static(shared);
|
Self::revert_region_selection_static(shared);
|
||||||
|
|
||||||
|
|
@ -3553,7 +3620,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: update region
|
// Mouse drag: update region
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
match shared.tool_state {
|
match shared.tool_state {
|
||||||
ToolState::RegionSelectingRect { ref start, .. } => {
|
ToolState::RegionSelectingRect { ref start, .. } => {
|
||||||
let start = *start;
|
let start = *start;
|
||||||
|
|
@ -3574,7 +3641,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: execute region selection
|
// Mouse up: execute region selection
|
||||||
if response.drag_stopped() {
|
if self.rsp_drag_stopped(response) {
|
||||||
let region_path = match &*shared.tool_state {
|
let region_path = match &*shared.tool_state {
|
||||||
ToolState::RegionSelectingRect { start, current } => {
|
ToolState::RegionSelectingRect { start, current } => {
|
||||||
let min_x = start.x.min(current.x);
|
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);
|
let point = Point::new(world_pos.x as f64, world_pos.y as f64);
|
||||||
|
|
||||||
// Mouse down: start drawing path (snap the first point)
|
// 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);
|
let snapped_start = self.snap_point(point, shared);
|
||||||
*shared.tool_state = ToolState::DrawingPath {
|
*shared.tool_state = ToolState::DrawingPath {
|
||||||
points: vec![snapped_start],
|
points: vec![snapped_start],
|
||||||
|
|
@ -3826,7 +3893,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: add points to path (no snapping for intermediate freehand points)
|
// Mouse drag: add points to path (no snapping for intermediate freehand points)
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
self.current_snap = None;
|
self.current_snap = None;
|
||||||
if let ToolState::DrawingPath { points, simplify_mode: _ } = &mut *shared.tool_state {
|
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)
|
// 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
|
// 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)
|
// Snap the final point (extract last point first to avoid borrow conflict)
|
||||||
let last_point = if let ToolState::DrawingPath { points, .. } = &*shared.tool_state {
|
let last_point = if let ToolState::DrawingPath { points, .. } = &*shared.tool_state {
|
||||||
if points.len() >= 2 { Some(*points.last().unwrap()) } else { None }
|
if points.len() >= 2 { Some(*points.last().unwrap()) } else { None }
|
||||||
|
|
@ -3958,7 +4025,7 @@ impl StagePane {
|
||||||
return;
|
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 click_point = Point::new(world_pos.x as f64, world_pos.y as f64);
|
||||||
let fill_color = ShapeColor::from_egui(*shared.fill_color);
|
let fill_color = ShapeColor::from_egui(*shared.fill_color);
|
||||||
|
|
||||||
|
|
@ -4448,7 +4515,7 @@ impl StagePane {
|
||||||
match shared.tool_state.clone() {
|
match shared.tool_state.clone() {
|
||||||
ToolState::Transforming { mode, start_mouse, original_bbox, .. } => {
|
ToolState::Transforming { mode, start_mouse, original_bbox, .. } => {
|
||||||
// Drag: apply transform preview to DCEL
|
// Drag: apply transform preview to DCEL
|
||||||
if response.dragged() {
|
if self.rsp_dragged(response) {
|
||||||
*shared.tool_state = ToolState::Transforming {
|
*shared.tool_state = ToolState::Transforming {
|
||||||
mode: mode.clone(),
|
mode: mode.clone(),
|
||||||
original_transforms: std::collections::HashMap::new(),
|
original_transforms: std::collections::HashMap::new(),
|
||||||
|
|
@ -4481,7 +4548,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release: finalize
|
// 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() {
|
if let Some(cache) = self.dcel_editing_cache.take() {
|
||||||
let dcel_after = {
|
let dcel_after = {
|
||||||
let document = shared.action_executor.document();
|
let document = shared.action_executor.document();
|
||||||
|
|
@ -4507,7 +4574,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idle: check for handle clicks to start a transform
|
// 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;
|
let tolerance = 10.0;
|
||||||
if let Some(mode) = Self::hit_test_transform_handle(point, bbox, tolerance) {
|
if let Some(mode) = Self::hit_test_transform_handle(point, bbox, tolerance) {
|
||||||
// Snapshot DCEL for undo
|
// Snapshot DCEL for undo
|
||||||
|
|
@ -4796,7 +4863,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse down: check if clicking on a handle
|
// 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
|
let tolerance = 10.0; // Click tolerance in world space
|
||||||
|
|
||||||
if let Some(mode) = Self::hit_test_transform_handle(point, bbox, tolerance) {
|
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
|
// 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() {
|
if let ToolState::Transforming { mode, original_transforms, pivot, start_mouse, original_bbox, .. } = shared.tool_state.clone() {
|
||||||
// Update current mouse position
|
// Update current mouse position
|
||||||
*shared.tool_state = ToolState::Transforming {
|
*shared.tool_state = ToolState::Transforming {
|
||||||
|
|
@ -4864,7 +4931,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse up: finalize transform
|
// 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() {
|
if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use lightningbeam_core::actions::TransformClipInstancesAction;
|
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) ===
|
// === Mouse down: hit test handles (using the same handle positions and order as cursor logic) ===
|
||||||
let should_start_transform = (response.drag_started() || response.clicked())
|
let should_start_transform = (self.rsp_drag_started(response) || self.rsp_clicked(response))
|
||||||
|| (matches!(*shared.tool_state, ToolState::Idle) && ui.input(|i| i.pointer.primary_down()) && response.hovered());
|
|| (matches!(*shared.tool_state, ToolState::Idle) && self.rsp_primary_down(ui) && response.hovered());
|
||||||
|
|
||||||
if should_start_transform && matches!(*shared.tool_state, ToolState::Idle) {
|
if should_start_transform && matches!(*shared.tool_state, ToolState::Idle) {
|
||||||
// Check rotation handle (same as cursor logic)
|
// Check rotation handle (same as cursor logic)
|
||||||
|
|
@ -5245,7 +5312,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mouse drag: apply transform in local space
|
// 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() {
|
if let ToolState::Transforming { mode, original_transforms, start_mouse, current_mouse: _, .. } = shared.tool_state.clone() {
|
||||||
// Update current mouse
|
// Update current mouse
|
||||||
if let ToolState::Transforming { mode, original_transforms, pivot, start_mouse, original_bbox, current_mouse: _ } = shared.tool_state.clone() {
|
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
|
// 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() {
|
if let ToolState::Transforming { original_transforms, .. } = shared.tool_state.clone() {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use lightningbeam_core::actions::TransformClipInstancesAction;
|
use lightningbeam_core::actions::TransformClipInstancesAction;
|
||||||
|
|
@ -5681,7 +5748,36 @@ impl StagePane {
|
||||||
use lightningbeam_core::tool::ToolState;
|
use lightningbeam_core::tool::ToolState;
|
||||||
use vello::kurbo::Point;
|
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() {
|
match shared.tool_state.clone() {
|
||||||
ToolState::DraggingSelection { start_mouse, original_positions, .. } => {
|
ToolState::DraggingSelection { start_mouse, original_positions, .. } => {
|
||||||
// Get last known mouse position (will be at edge if offscreen)
|
// 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
|
// Check for synthetic input from test mode replay (debug builds only)
|
||||||
if !response.hovered() {
|
#[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.is_panning = false;
|
||||||
self.last_pan_pos = None;
|
self.last_pan_pos = None;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// During replay with no synthetic event this frame, skip all input processing
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if is_replaying && !has_synthetic {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let scroll_delta = ui.input(|i| i.smooth_scroll_delta);
|
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 alt_held = ui.input(|i| i.modifiers.alt);
|
||||||
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
||||||
let shift_held = ui.input(|i| i.modifiers.shift);
|
let shift_held = ui.input(|i| i.modifiers.shift);
|
||||||
|
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
|
||||||
|
let mouse_canvas_pos = mouse_pos - rect.min;
|
||||||
|
let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom;
|
||||||
|
let wp = self.doc_to_clip_local(world_pos_doc, shared);
|
||||||
|
(wp, alt_held, ctrl_held, shift_held, response.drag_started(), response.dragged(), response.drag_stopped())
|
||||||
|
};
|
||||||
|
|
||||||
// Get mouse position for zoom-to-cursor
|
#[cfg(not(debug_assertions))]
|
||||||
|
let (world_pos, alt_held, ctrl_held, shift_held, _drag_started, _dragged, _drag_stopped) = {
|
||||||
|
let alt_held = ui.input(|i| i.modifiers.alt);
|
||||||
|
let ctrl_held = ui.input(|i| i.modifiers.ctrl || i.modifiers.command);
|
||||||
|
let shift_held = ui.input(|i| i.modifiers.shift);
|
||||||
|
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
|
||||||
|
let mouse_canvas_pos = mouse_pos - rect.min;
|
||||||
|
let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom;
|
||||||
|
let wp = self.doc_to_clip_local(world_pos_doc, shared);
|
||||||
|
(wp, alt_held, ctrl_held, shift_held, response.drag_started(), response.dragged(), response.drag_stopped())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Record mouse events for test mode (debug builds only) — skip during replay
|
||||||
|
//
|
||||||
|
// IMPORTANT: We use `primary_pressed` (fires immediately on button down) for MouseDown
|
||||||
|
// instead of `drag_started` (fires after egui's drag threshold, ~6-10px of movement).
|
||||||
|
// The select tool hit-tests on `primary_pressed`, so we must record the position at
|
||||||
|
// that moment. The `drag_started` frame is recorded as MouseDrag since the press
|
||||||
|
// was already captured.
|
||||||
|
#[cfg(debug_assertions)]
|
||||||
|
if !is_replaying {
|
||||||
|
use lightningbeam_core::test_mode::{SerPoint, TestEventKind};
|
||||||
|
let pos = SerPoint { x: world_pos.x as f64, y: world_pos.y as f64 };
|
||||||
|
let primary_just_pressed = response.hovered() && ui.input(|i| i.pointer.primary_pressed());
|
||||||
|
if primary_just_pressed {
|
||||||
|
shared.test_mode.record_event(TestEventKind::MouseDown { pos });
|
||||||
|
} else if drag_stopped {
|
||||||
|
// Emit a final MouseDrag at the release position to close the gap
|
||||||
|
// between the last drag frame and the release (the mouse moves between frames)
|
||||||
|
shared.test_mode.record_event(TestEventKind::MouseDrag { pos });
|
||||||
|
shared.test_mode.record_event(TestEventKind::MouseUp { pos });
|
||||||
|
} else if drag_started || dragged {
|
||||||
|
// drag_started after primary_pressed is just the first drag motion
|
||||||
|
shared.test_mode.record_event(TestEventKind::MouseDrag { pos });
|
||||||
|
} else if response.hovered() {
|
||||||
|
shared.test_mode.record_event(TestEventKind::MouseMove { pos });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mouse position for zoom-to-cursor (needed for pan/zoom handling below)
|
||||||
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
|
let mouse_pos = response.hover_pos().unwrap_or(rect.center());
|
||||||
let mouse_canvas_pos = mouse_pos - rect.min;
|
let mouse_canvas_pos = mouse_pos - rect.min;
|
||||||
|
|
||||||
// Convert screen position to world position (accounting for pan and zoom)
|
// Set replay override so wrapper methods return synthetic drag state
|
||||||
// When inside a clip, further transform to clip-local coordinates
|
#[cfg(debug_assertions)]
|
||||||
let world_pos_doc = (mouse_canvas_pos - self.pan_offset) / self.zoom;
|
if synthetic_input.is_some() {
|
||||||
let world_pos = self.doc_to_clip_local(world_pos_doc, shared);
|
self.replay_override = Some(ReplayDragState {
|
||||||
|
drag_started,
|
||||||
|
dragged,
|
||||||
|
drag_stopped,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Handle tool input (only if not using Alt modifier for panning)
|
// Handle tool input (only if not using Alt modifier for panning)
|
||||||
if !alt_held {
|
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
|
// Delete/Backspace: remove selected DCEL elements
|
||||||
if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
|
if ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace)) {
|
||||||
if shared.selection.has_dcel_selection() {
|
if shared.selection.has_dcel_selection() {
|
||||||
|
|
@ -5925,6 +6097,8 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip real scroll/zoom/pan input during replay
|
||||||
|
if !is_replaying {
|
||||||
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
// Distinguish between mouse wheel (discrete) and trackpad (smooth)
|
||||||
let mut handled = false;
|
let mut handled = false;
|
||||||
ui.input(|i| {
|
ui.input(|i| {
|
||||||
|
|
@ -5979,6 +6153,7 @@ impl StagePane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render vector editing overlays (vertices, control points, handles)
|
/// Render vector editing overlays (vertices, control points, handles)
|
||||||
fn render_vector_editing_overlays(
|
fn render_vector_editing_overlays(
|
||||||
|
|
@ -6326,7 +6501,7 @@ impl PaneRenderer for StagePane {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle drop on mouse release
|
// 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: {:?}",
|
eprintln!("DEBUG STAGE DROP: Dropping clip type {:?}, linked_audio: {:?}",
|
||||||
dragging.clip_type, dragging.linked_audio_clip_id);
|
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)
|
// Render snap indicator (works for all tools, not just Select/BezierEdit)
|
||||||
self.render_snap_indicator(ui, rect, shared);
|
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
|
// Set custom tool cursor when pointer is over the stage canvas
|
||||||
// (system cursors from transform handles take priority via render_overlay check)
|
// (system cursors from transform handles take priority via render_overlay check)
|
||||||
if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {
|
if let Some(pos) = ui.input(|i| i.pointer.hover_pos()) {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,941 @@
|
||||||
|
//! Debug test mode — input recording, panic capture, and visual replay.
|
||||||
|
//!
|
||||||
|
//! Gated behind `#[cfg(debug_assertions)]` at the module level in main.rs.
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
use lightningbeam_core::test_mode::*;
|
||||||
|
use std::collections::VecDeque;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::time::Instant;
|
||||||
|
use vello::kurbo::Point;
|
||||||
|
|
||||||
|
/// Maximum events kept in the always-on ring buffer for crash capture
|
||||||
|
const RING_BUFFER_SIZE: usize = 1000;
|
||||||
|
|
||||||
|
/// How often to snapshot state for the panic hook (every N events)
|
||||||
|
const PANIC_SNAPSHOT_INTERVAL: usize = 50;
|
||||||
|
|
||||||
|
// ---- Synthetic input for replay ----
|
||||||
|
|
||||||
|
/// Synthetic input data injected during replay, consumed by stage handle_input
|
||||||
|
pub struct SyntheticInput {
|
||||||
|
pub world_pos: Point,
|
||||||
|
pub drag_started: bool,
|
||||||
|
pub dragged: bool,
|
||||||
|
pub drag_stopped: bool,
|
||||||
|
#[allow(dead_code)] // Part of the synthetic input API, consumed when replay handles held-button state
|
||||||
|
pub primary_down: bool,
|
||||||
|
pub shift: bool,
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- State machine ----
|
||||||
|
|
||||||
|
pub enum TestModeOp {
|
||||||
|
Idle,
|
||||||
|
Recording(TestRecorder),
|
||||||
|
Playing(TestPlayer),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestRecorder {
|
||||||
|
pub test_case: TestCase,
|
||||||
|
pub start_time: Instant,
|
||||||
|
event_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestRecorder {
|
||||||
|
fn new(name: String, canvas_state: CanvasState) -> Self {
|
||||||
|
Self {
|
||||||
|
test_case: TestCase::new(name, canvas_state),
|
||||||
|
start_time: Instant::now(),
|
||||||
|
event_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record(&mut self, kind: TestEventKind) {
|
||||||
|
let timestamp_ms = self.start_time.elapsed().as_millis() as u64;
|
||||||
|
let index = self.event_count;
|
||||||
|
self.event_count += 1;
|
||||||
|
self.test_case.events.push(TestEvent {
|
||||||
|
index,
|
||||||
|
timestamp_ms,
|
||||||
|
kind,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct TestPlayer {
|
||||||
|
pub test_case: TestCase,
|
||||||
|
/// Next event index to execute
|
||||||
|
pub cursor: usize,
|
||||||
|
pub auto_playing: bool,
|
||||||
|
pub auto_play_delay_ms: u64,
|
||||||
|
pub last_step_time: Option<Instant>,
|
||||||
|
/// Collapse consecutive move/drag events in the event list and step through them as one batch
|
||||||
|
pub skip_consecutive_moves: bool,
|
||||||
|
/// When set, auto-play runs at max speed until cursor reaches this index, then stops
|
||||||
|
batch_end: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestPlayer {
|
||||||
|
fn new(test_case: TestCase) -> Self {
|
||||||
|
Self {
|
||||||
|
test_case,
|
||||||
|
cursor: 0,
|
||||||
|
auto_playing: false,
|
||||||
|
auto_play_delay_ms: 100,
|
||||||
|
last_step_time: None,
|
||||||
|
skip_consecutive_moves: true, // on by default
|
||||||
|
batch_end: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance cursor by one event and return it, or None if finished.
|
||||||
|
pub fn step_forward(&mut self) -> Option<&TestEvent> {
|
||||||
|
if self.cursor >= self.test_case.events.len() {
|
||||||
|
self.auto_playing = false;
|
||||||
|
self.batch_end = None;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let idx = self.cursor;
|
||||||
|
self.cursor += 1;
|
||||||
|
self.last_step_time = Some(Instant::now());
|
||||||
|
|
||||||
|
// Check if batch is done
|
||||||
|
if let Some(end) = self.batch_end {
|
||||||
|
if self.cursor >= end {
|
||||||
|
self.auto_playing = false;
|
||||||
|
self.batch_end = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(&self.test_case.events[idx])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a batch-replay of consecutive move/drag events from the current cursor.
|
||||||
|
/// Returns the first event in the batch, sets up auto-play for the rest.
|
||||||
|
pub fn step_or_batch(&mut self) -> Option<&TestEvent> {
|
||||||
|
if self.cursor >= self.test_case.events.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.skip_consecutive_moves {
|
||||||
|
let disc = move_discriminant(&self.test_case.events[self.cursor].kind);
|
||||||
|
if let Some(d) = disc {
|
||||||
|
// Find end of consecutive run
|
||||||
|
let mut end = self.cursor + 1;
|
||||||
|
while end < self.test_case.events.len()
|
||||||
|
&& move_discriminant(&self.test_case.events[end].kind) == Some(d)
|
||||||
|
{
|
||||||
|
end += 1;
|
||||||
|
}
|
||||||
|
if end > self.cursor + 1 {
|
||||||
|
// Multi-event batch — auto-play through it at max speed
|
||||||
|
self.batch_end = Some(end);
|
||||||
|
self.auto_playing = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.step_forward()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether auto-play should step this frame
|
||||||
|
pub fn should_auto_step(&self) -> bool {
|
||||||
|
if !self.auto_playing {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Batch replay: every frame, no delay
|
||||||
|
if self.batch_end.is_some() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Normal auto-play: respect delay setting
|
||||||
|
match self.last_step_time {
|
||||||
|
None => true,
|
||||||
|
Some(t) => t.elapsed().as_millis() as u64 >= self.auto_play_delay_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn progress(&self) -> (usize, usize) {
|
||||||
|
(self.cursor, self.test_case.events.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.cursor = 0;
|
||||||
|
self.auto_playing = false;
|
||||||
|
self.last_step_time = None;
|
||||||
|
self.batch_end = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Main state ----
|
||||||
|
|
||||||
|
pub struct TestModeState {
|
||||||
|
/// Whether the test mode sidebar is visible
|
||||||
|
pub active: bool,
|
||||||
|
/// Current operation
|
||||||
|
pub mode: TestModeOp,
|
||||||
|
/// Directory for test case files
|
||||||
|
pub test_dir: PathBuf,
|
||||||
|
/// List of available test files
|
||||||
|
pub available_tests: Vec<PathBuf>,
|
||||||
|
/// Name field for new recordings
|
||||||
|
pub new_test_name: String,
|
||||||
|
/// Transient status message
|
||||||
|
pub status_message: Option<(String, Instant)>,
|
||||||
|
/// Shared with panic hook — periodically updated with current state
|
||||||
|
pub panic_snapshot: Arc<Mutex<Option<TestCase>>>,
|
||||||
|
/// Current in-flight event, set before processing. If a panic occurs during
|
||||||
|
/// processing, the panic hook appends this to the saved test case.
|
||||||
|
pub pending_event: Arc<Mutex<Option<TestEvent>>>,
|
||||||
|
/// Always-on ring buffer of last N events (for crash capture outside test mode)
|
||||||
|
pub event_ring: VecDeque<TestEvent>,
|
||||||
|
pub ring_start_time: Instant,
|
||||||
|
ring_event_count: usize,
|
||||||
|
/// Counter since last panic snapshot update
|
||||||
|
events_since_snapshot: usize,
|
||||||
|
/// Last replayed world-space position (for ghost cursor rendering on stage)
|
||||||
|
pub replay_cursor_pos: Option<(f64, f64)>,
|
||||||
|
/// Shared with panic hook — when true, panics during replay don't save new crash files
|
||||||
|
pub is_replaying: Arc<AtomicBool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TestModeState {
|
||||||
|
pub fn new(panic_snapshot: Arc<Mutex<Option<TestCase>>>, pending_event: Arc<Mutex<Option<TestEvent>>>, is_replaying: Arc<AtomicBool>) -> Self {
|
||||||
|
let test_dir = directories::ProjectDirs::from("", "", "lightningbeam")
|
||||||
|
.map(|dirs| dirs.data_dir().join("test_cases"))
|
||||||
|
.unwrap_or_else(|| PathBuf::from("test_cases"));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
active: false,
|
||||||
|
mode: TestModeOp::Idle,
|
||||||
|
test_dir,
|
||||||
|
available_tests: Vec::new(),
|
||||||
|
new_test_name: String::new(),
|
||||||
|
status_message: None,
|
||||||
|
panic_snapshot,
|
||||||
|
pending_event,
|
||||||
|
event_ring: VecDeque::with_capacity(RING_BUFFER_SIZE),
|
||||||
|
ring_start_time: Instant::now(),
|
||||||
|
ring_event_count: 0,
|
||||||
|
events_since_snapshot: 0,
|
||||||
|
replay_cursor_pos: None,
|
||||||
|
is_replaying,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Store the current in-flight event for panic capture.
|
||||||
|
/// Called before processing so the panic hook can grab it if processing panics.
|
||||||
|
pub fn set_pending_event(&self, kind: TestEventKind) {
|
||||||
|
let event = TestEvent {
|
||||||
|
index: self.ring_event_count,
|
||||||
|
timestamp_ms: self.ring_start_time.elapsed().as_millis() as u64,
|
||||||
|
kind,
|
||||||
|
};
|
||||||
|
if let Ok(mut guard) = self.pending_event.try_lock() {
|
||||||
|
*guard = Some(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record an event — always appends to ring buffer, and to active recording if any
|
||||||
|
pub fn record_event(&mut self, kind: TestEventKind) {
|
||||||
|
let timestamp_ms = self.ring_start_time.elapsed().as_millis() as u64;
|
||||||
|
let index = self.ring_event_count;
|
||||||
|
self.ring_event_count += 1;
|
||||||
|
|
||||||
|
let event = TestEvent {
|
||||||
|
index,
|
||||||
|
timestamp_ms,
|
||||||
|
kind: kind.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Always append to ring buffer
|
||||||
|
if self.event_ring.len() >= RING_BUFFER_SIZE {
|
||||||
|
self.event_ring.pop_front();
|
||||||
|
}
|
||||||
|
self.event_ring.push_back(event);
|
||||||
|
|
||||||
|
// Append to active recording if any
|
||||||
|
if let TestModeOp::Recording(ref mut recorder) = self.mode {
|
||||||
|
recorder.record(kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodically update panic snapshot
|
||||||
|
self.events_since_snapshot += 1;
|
||||||
|
if self.events_since_snapshot >= PANIC_SNAPSHOT_INTERVAL {
|
||||||
|
self.events_since_snapshot = 0;
|
||||||
|
self.update_panic_snapshot();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start a new recording
|
||||||
|
pub fn start_recording(&mut self, name: String, canvas_state: CanvasState) {
|
||||||
|
self.mode = TestModeOp::Recording(TestRecorder::new(name, canvas_state));
|
||||||
|
self.set_status("Recording started");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop recording and save to disk. Returns path if saved successfully.
|
||||||
|
pub fn stop_recording(&mut self) -> Option<PathBuf> {
|
||||||
|
let recorder = match std::mem::replace(&mut self.mode, TestModeOp::Idle) {
|
||||||
|
TestModeOp::Recording(r) => r,
|
||||||
|
other => {
|
||||||
|
self.mode = other;
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let test_case = recorder.test_case;
|
||||||
|
let filename = sanitize_filename(&test_case.name);
|
||||||
|
let path = self.test_dir.join(format!("{}.json", filename));
|
||||||
|
|
||||||
|
match test_case.save_to_file(&path) {
|
||||||
|
Ok(()) => {
|
||||||
|
self.set_status(&format!("Saved: {}", path.display()));
|
||||||
|
self.refresh_test_list();
|
||||||
|
Some(path)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.set_status(&format!("Save failed: {}", e));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Discard the current recording
|
||||||
|
pub fn discard_recording(&mut self) {
|
||||||
|
self.mode = TestModeOp::Idle;
|
||||||
|
self.set_status("Recording discarded");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a test case for playback
|
||||||
|
pub fn load_test(&mut self, path: &PathBuf) {
|
||||||
|
match TestCase::load_from_file(path) {
|
||||||
|
Ok(test_case) => {
|
||||||
|
self.set_status(&format!("Loaded: {} ({} events)", test_case.name, test_case.events.len()));
|
||||||
|
self.mode = TestModeOp::Playing(TestPlayer::new(test_case));
|
||||||
|
self.is_replaying.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
self.set_status(&format!("Load failed: {}", e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stop playback and return to idle
|
||||||
|
pub fn stop_playback(&mut self) {
|
||||||
|
self.mode = TestModeOp::Idle;
|
||||||
|
self.is_replaying.store(false, Ordering::SeqCst);
|
||||||
|
self.set_status("Playback stopped");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Called from panic hook — saves ring buffer or active recording as a crash test case.
|
||||||
|
/// Also grabs the pending in-flight event (if any) so the crash-triggering event is captured.
|
||||||
|
/// Skips saving when replaying a recorded test (to avoid duplicate crash files).
|
||||||
|
pub fn record_panic(
|
||||||
|
panic_snapshot: &Arc<Mutex<Option<TestCase>>>,
|
||||||
|
pending_event: &Arc<Mutex<Option<TestEvent>>>,
|
||||||
|
is_replaying: &Arc<AtomicBool>,
|
||||||
|
msg: String,
|
||||||
|
backtrace: String,
|
||||||
|
test_dir: &PathBuf,
|
||||||
|
) {
|
||||||
|
if is_replaying.load(Ordering::SeqCst) {
|
||||||
|
eprintln!("[TEST MODE] Panic during replay — not saving duplicate crash file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(mut guard) = panic_snapshot.lock() {
|
||||||
|
let mut test_case = guard.take().unwrap_or_else(|| {
|
||||||
|
TestCase::new(
|
||||||
|
"crash_capture".to_string(),
|
||||||
|
CanvasState {
|
||||||
|
zoom: 1.0,
|
||||||
|
pan_offset: (0.0, 0.0),
|
||||||
|
selected_tool: "Unknown".to_string(),
|
||||||
|
fill_color: [0, 0, 0, 255],
|
||||||
|
stroke_color: [0, 0, 0, 255],
|
||||||
|
stroke_width: 3.0,
|
||||||
|
fill_enabled: true,
|
||||||
|
snap_enabled: true,
|
||||||
|
polygon_sides: 5,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append the in-flight event that was being processed when the panic occurred
|
||||||
|
if let Ok(mut pending) = pending_event.try_lock() {
|
||||||
|
if let Some(event) = pending.take() {
|
||||||
|
test_case.events.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test_case.ended_with_panic = true;
|
||||||
|
test_case.panic_message = Some(msg);
|
||||||
|
test_case.panic_backtrace = Some(backtrace);
|
||||||
|
|
||||||
|
let timestamp = format_timestamp();
|
||||||
|
let path = test_dir.join(format!("crash_{}.json", timestamp));
|
||||||
|
|
||||||
|
if let Err(e) = test_case.save_to_file(&path) {
|
||||||
|
eprintln!("[TEST MODE] Failed to save crash test case: {}", e);
|
||||||
|
} else {
|
||||||
|
eprintln!("[TEST MODE] Crash test case saved to: {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the list of available test files
|
||||||
|
pub fn refresh_test_list(&mut self) {
|
||||||
|
self.available_tests.clear();
|
||||||
|
if let Ok(entries) = std::fs::read_dir(&self.test_dir) {
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map_or(false, |ext| ext == "json") {
|
||||||
|
self.available_tests.push(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.available_tests.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(&mut self, msg: &str) {
|
||||||
|
self.status_message = Some((msg.to_string(), Instant::now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Update the panic snapshot with current ring buffer state
|
||||||
|
fn update_panic_snapshot(&self) {
|
||||||
|
if let Ok(mut guard) = self.panic_snapshot.try_lock() {
|
||||||
|
let events: Vec<TestEvent> = self.event_ring.iter().cloned().collect();
|
||||||
|
let mut snapshot = TestCase::new(
|
||||||
|
"ring_buffer_snapshot".to_string(),
|
||||||
|
CanvasState {
|
||||||
|
zoom: 1.0,
|
||||||
|
pan_offset: (0.0, 0.0),
|
||||||
|
selected_tool: "Unknown".to_string(),
|
||||||
|
fill_color: [0, 0, 0, 255],
|
||||||
|
stroke_color: [0, 0, 0, 255],
|
||||||
|
stroke_width: 3.0,
|
||||||
|
fill_enabled: true,
|
||||||
|
snap_enabled: true,
|
||||||
|
polygon_sides: 5,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
snapshot.events = events;
|
||||||
|
*guard = Some(snapshot);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Replay frame ----
|
||||||
|
|
||||||
|
/// Result of stepping a replay frame — carries both mouse input and non-mouse actions
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ReplayFrame {
|
||||||
|
pub synthetic_input: Option<SyntheticInput>,
|
||||||
|
pub tool_change: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Sidebar UI ----
|
||||||
|
|
||||||
|
/// Render the test mode sidebar panel. Returns a ReplayFrame with actions to apply.
|
||||||
|
pub fn render_sidebar(
|
||||||
|
ctx: &egui::Context,
|
||||||
|
state: &mut TestModeState,
|
||||||
|
) -> ReplayFrame {
|
||||||
|
if !state.active {
|
||||||
|
return ReplayFrame::default();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut frame = ReplayFrame::default();
|
||||||
|
let mut action = SidebarAction::None;
|
||||||
|
|
||||||
|
egui::SidePanel::right("test_mode_panel")
|
||||||
|
.default_width(300.0)
|
||||||
|
.min_width(250.0)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.heading("TEST MODE");
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("X").clicked() {
|
||||||
|
state.active = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
// Status message (auto-clear after 5s)
|
||||||
|
if let Some((ref msg, when)) = state.status_message {
|
||||||
|
if when.elapsed().as_secs() < 5 {
|
||||||
|
ui.colored_label(egui::Color32::YELLOW, msg);
|
||||||
|
ui.separator();
|
||||||
|
} else {
|
||||||
|
state.status_message = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match &state.mode {
|
||||||
|
TestModeOp::Idle => {
|
||||||
|
render_idle_ui(ui, state, &mut action);
|
||||||
|
}
|
||||||
|
TestModeOp::Recording(_) => {
|
||||||
|
render_recording_ui(ui, state, &mut action);
|
||||||
|
}
|
||||||
|
TestModeOp::Playing(_) => {
|
||||||
|
frame = render_playing_ui(ui, state, &mut action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Execute deferred actions (avoid borrow conflicts)
|
||||||
|
match action {
|
||||||
|
SidebarAction::None => {}
|
||||||
|
SidebarAction::StartRecording(name, canvas) => {
|
||||||
|
state.start_recording(name, canvas);
|
||||||
|
}
|
||||||
|
SidebarAction::StopRecording => {
|
||||||
|
state.stop_recording();
|
||||||
|
}
|
||||||
|
SidebarAction::DiscardRecording => {
|
||||||
|
state.discard_recording();
|
||||||
|
}
|
||||||
|
SidebarAction::LoadTest(path) => {
|
||||||
|
state.load_test(&path);
|
||||||
|
}
|
||||||
|
SidebarAction::StopPlayback => {
|
||||||
|
state.stop_playback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update ghost cursor position from the replay frame
|
||||||
|
if let Some(ref syn) = frame.synthetic_input {
|
||||||
|
state.replay_cursor_pos = Some((syn.world_pos.x, syn.world_pos.y));
|
||||||
|
} else if !matches!(state.mode, TestModeOp::Playing(_)) {
|
||||||
|
state.replay_cursor_pos = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
frame
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SidebarAction {
|
||||||
|
None,
|
||||||
|
StartRecording(String, CanvasState),
|
||||||
|
StopRecording,
|
||||||
|
DiscardRecording,
|
||||||
|
LoadTest(PathBuf),
|
||||||
|
StopPlayback,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_idle_ui(ui: &mut egui::Ui, state: &mut TestModeState, action: &mut SidebarAction) {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Record").clicked() {
|
||||||
|
let name = if state.new_test_name.is_empty() {
|
||||||
|
format!("test_{}", format_timestamp())
|
||||||
|
} else {
|
||||||
|
state.new_test_name.clone()
|
||||||
|
};
|
||||||
|
// Default canvas state — will be overwritten by caller with real values
|
||||||
|
let canvas = CanvasState {
|
||||||
|
zoom: 1.0,
|
||||||
|
pan_offset: (0.0, 0.0),
|
||||||
|
selected_tool: "Select".to_string(),
|
||||||
|
fill_color: [100, 100, 255, 255],
|
||||||
|
stroke_color: [0, 0, 0, 255],
|
||||||
|
stroke_width: 3.0,
|
||||||
|
fill_enabled: true,
|
||||||
|
snap_enabled: true,
|
||||||
|
polygon_sides: 5,
|
||||||
|
};
|
||||||
|
*action = SidebarAction::StartRecording(name, canvas);
|
||||||
|
}
|
||||||
|
if ui.button("Load Test...").clicked() {
|
||||||
|
if let Some(path) = rfd::FileDialog::new()
|
||||||
|
.add_filter("JSON", &["json"])
|
||||||
|
.set_directory(&state.test_dir)
|
||||||
|
.pick_file()
|
||||||
|
{
|
||||||
|
*action = SidebarAction::LoadTest(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Name:");
|
||||||
|
ui.text_edit_singleline(&mut state.new_test_name);
|
||||||
|
});
|
||||||
|
|
||||||
|
// List available tests
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.label(egui::RichText::new("Saved Tests").strong());
|
||||||
|
|
||||||
|
if state.available_tests.is_empty() {
|
||||||
|
ui.colored_label(egui::Color32::GRAY, "(none)");
|
||||||
|
} else {
|
||||||
|
egui::ScrollArea::vertical().max_height(200.0).show(ui, |ui| {
|
||||||
|
let mut load_path = None;
|
||||||
|
for path in &state.available_tests {
|
||||||
|
let name = path.file_name()
|
||||||
|
.map(|s| s.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if ui.selectable_label(false, &name).clicked() {
|
||||||
|
load_path = Some(path.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(path) = load_path {
|
||||||
|
*action = SidebarAction::LoadTest(path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ring buffer info
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.colored_label(
|
||||||
|
egui::Color32::GRAY,
|
||||||
|
format!("Ring buffer: {} events (auto-saved on crash)", state.event_ring.len()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_recording_ui(ui: &mut egui::Ui, state: &mut TestModeState, action: &mut SidebarAction) {
|
||||||
|
let event_count = match &state.mode {
|
||||||
|
TestModeOp::Recording(r) => r.test_case.events.len(),
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(255, 80, 80),
|
||||||
|
format!("Recording... ({} events)", event_count));
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Stop & Save").clicked() {
|
||||||
|
*action = SidebarAction::StopRecording;
|
||||||
|
}
|
||||||
|
if ui.button("Discard").clicked() {
|
||||||
|
*action = SidebarAction::DiscardRecording;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show recent events
|
||||||
|
if let TestModeOp::Recording(ref recorder) = state.mode {
|
||||||
|
render_event_list(ui, &recorder.test_case.events, None, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_playing_ui(
|
||||||
|
ui: &mut egui::Ui,
|
||||||
|
state: &mut TestModeState,
|
||||||
|
action: &mut SidebarAction,
|
||||||
|
) -> ReplayFrame {
|
||||||
|
let mut frame = ReplayFrame::default();
|
||||||
|
|
||||||
|
let (cursor, total, test_name, has_panic, panic_msg, auto_playing, delay_ms) = match &state.mode {
|
||||||
|
TestModeOp::Playing(p) => {
|
||||||
|
let (c, t) = p.progress();
|
||||||
|
(
|
||||||
|
c, t,
|
||||||
|
p.test_case.name.clone(),
|
||||||
|
p.test_case.ended_with_panic,
|
||||||
|
p.test_case.panic_message.clone(),
|
||||||
|
p.auto_playing,
|
||||||
|
p.auto_play_delay_ms,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => return ReplayFrame::default(),
|
||||||
|
};
|
||||||
|
|
||||||
|
ui.label(format!("Test: {}", test_name));
|
||||||
|
if has_panic {
|
||||||
|
ui.colored_label(egui::Color32::RED, "Ended with PANIC");
|
||||||
|
if let Some(ref msg) = panic_msg {
|
||||||
|
let display_msg = if msg.len() > 120 { &msg[..120] } else { msg.as_str() };
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), display_msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ui.label(format!("{}/{} events", cursor, total));
|
||||||
|
|
||||||
|
// Transport controls
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
// Reset
|
||||||
|
if ui.button("|<").clicked() {
|
||||||
|
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||||
|
p.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Step back (reset to cursor - 1)
|
||||||
|
if ui.button("<Step").clicked() {
|
||||||
|
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||||
|
if p.cursor > 0 {
|
||||||
|
p.cursor -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Step forward (batches consecutive move/drag events when skip is on)
|
||||||
|
if ui.button("Step>").clicked() {
|
||||||
|
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||||
|
if let Some(event) = p.step_or_batch() {
|
||||||
|
frame = event_to_replay_frame(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Auto-play toggle
|
||||||
|
let auto_label = if auto_playing { "||Pause" } else { ">>Auto" };
|
||||||
|
if ui.button(auto_label).clicked() {
|
||||||
|
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||||
|
p.auto_playing = !p.auto_playing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stop
|
||||||
|
if ui.button("Stop").clicked() {
|
||||||
|
*action = SidebarAction::StopPlayback;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Speed slider
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Speed:");
|
||||||
|
let mut delay = delay_ms as f32;
|
||||||
|
if ui.add(egui::Slider::new(&mut delay, 10.0..=500.0).suffix("ms")).changed() {
|
||||||
|
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||||
|
p.auto_play_delay_ms = delay as u64;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skip consecutive moves toggle
|
||||||
|
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||||
|
ui.checkbox(&mut p.skip_consecutive_moves, "Skip consecutive moves");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-step
|
||||||
|
if auto_playing {
|
||||||
|
if let TestModeOp::Playing(ref mut p) = state.mode {
|
||||||
|
if p.should_auto_step() {
|
||||||
|
if let Some(event) = p.step_forward() {
|
||||||
|
frame = event_to_replay_frame(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Request continuous repaint during auto-play
|
||||||
|
ui.ctx().request_repaint();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event list
|
||||||
|
if let TestModeOp::Playing(ref player) = state.mode {
|
||||||
|
render_event_list(ui, &player.test_case.events, Some(player.cursor), player.skip_consecutive_moves);
|
||||||
|
}
|
||||||
|
|
||||||
|
frame
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_event_list(ui: &mut egui::Ui, events: &[TestEvent], cursor: Option<usize>, skip_moves: bool) {
|
||||||
|
ui.add_space(8.0);
|
||||||
|
ui.separator();
|
||||||
|
ui.label(egui::RichText::new("Events").strong());
|
||||||
|
|
||||||
|
// Build filtered index list when skip_moves is on
|
||||||
|
let filtered: Vec<usize> = if skip_moves {
|
||||||
|
filter_consecutive_moves(events)
|
||||||
|
} else {
|
||||||
|
(0..events.len()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.auto_shrink([false; 2])
|
||||||
|
.max_height(ui.available_height() - 20.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
// Find the cursor position within the filtered list
|
||||||
|
let cursor_filtered_pos = cursor.and_then(|c| {
|
||||||
|
// Find the filtered entry closest to (but not past) cursor
|
||||||
|
filtered.iter().rposition(|&idx| idx < c)
|
||||||
|
});
|
||||||
|
let focus = cursor_filtered_pos.unwrap_or(filtered.len().saturating_sub(1));
|
||||||
|
let start = focus.saturating_sub(50);
|
||||||
|
let end = (focus + 50).min(filtered.len());
|
||||||
|
|
||||||
|
for &event_idx in &filtered[start..end] {
|
||||||
|
let event = &events[event_idx];
|
||||||
|
let is_current = cursor.map_or(false, |c| event.index == c.saturating_sub(1));
|
||||||
|
let (prefix, color) = event_display_info(&event.kind, is_current);
|
||||||
|
|
||||||
|
let text = format!("{} #{} {}", prefix, event.index, format_event_kind(&event.kind));
|
||||||
|
let label = egui::RichText::new(text).color(color).monospace();
|
||||||
|
ui.label(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Filter event indices, keeping only the last of each consecutive run of same-type moves/drags
|
||||||
|
fn filter_consecutive_moves(events: &[TestEvent]) -> Vec<usize> {
|
||||||
|
let mut result = Vec::with_capacity(events.len());
|
||||||
|
let mut i = 0;
|
||||||
|
while i < events.len() {
|
||||||
|
let disc = move_discriminant(&events[i].kind);
|
||||||
|
if let Some(d) = disc {
|
||||||
|
// Scan ahead to find the last in this consecutive run
|
||||||
|
let mut last = i;
|
||||||
|
while last + 1 < events.len() && move_discriminant(&events[last + 1].kind) == Some(d) {
|
||||||
|
last += 1;
|
||||||
|
}
|
||||||
|
result.push(last);
|
||||||
|
i = last + 1;
|
||||||
|
} else {
|
||||||
|
result.push(i);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_display_info(kind: &TestEventKind, is_current: bool) -> (&'static str, egui::Color32) {
|
||||||
|
let prefix = if is_current { ">" } else { " " };
|
||||||
|
let color = match kind {
|
||||||
|
TestEventKind::MouseMove { .. } | TestEventKind::MouseDrag { .. } => {
|
||||||
|
egui::Color32::from_gray(140)
|
||||||
|
}
|
||||||
|
TestEventKind::MouseDown { .. } | TestEventKind::MouseUp { .. } => {
|
||||||
|
egui::Color32::from_gray(200)
|
||||||
|
}
|
||||||
|
TestEventKind::ToolChanged { .. } => egui::Color32::from_rgb(100, 150, 255),
|
||||||
|
TestEventKind::ActionExecuted { .. } => egui::Color32::from_rgb(100, 255, 100),
|
||||||
|
TestEventKind::KeyDown { .. } | TestEventKind::KeyUp { .. } => {
|
||||||
|
egui::Color32::from_rgb(200, 200, 100)
|
||||||
|
}
|
||||||
|
TestEventKind::Scroll { .. } => egui::Color32::from_gray(160),
|
||||||
|
};
|
||||||
|
(prefix, color)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_event_kind(kind: &TestEventKind) -> String {
|
||||||
|
match kind {
|
||||||
|
TestEventKind::MouseDown { pos } => format!("MouseDown ({:.1}, {:.1})", pos.x, pos.y),
|
||||||
|
TestEventKind::MouseUp { pos } => format!("MouseUp ({:.1}, {:.1})", pos.x, pos.y),
|
||||||
|
TestEventKind::MouseDrag { pos } => format!("MouseDrag ({:.1}, {:.1})", pos.x, pos.y),
|
||||||
|
TestEventKind::MouseMove { pos } => format!("MouseMove ({:.1}, {:.1})", pos.x, pos.y),
|
||||||
|
TestEventKind::Scroll { delta_x, delta_y } => {
|
||||||
|
format!("Scroll ({:.1}, {:.1})", delta_x, delta_y)
|
||||||
|
}
|
||||||
|
TestEventKind::KeyDown { key, .. } => format!("KeyDown {}", key),
|
||||||
|
TestEventKind::KeyUp { key, .. } => format!("KeyUp {}", key),
|
||||||
|
TestEventKind::ToolChanged { tool } => format!("ToolChanged: {}", tool),
|
||||||
|
TestEventKind::ActionExecuted { description } => {
|
||||||
|
format!("ActionExecuted: \"{}\"", description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a replayed TestEvent into a ReplayFrame carrying mouse input and/or tool changes
|
||||||
|
fn event_to_replay_frame(event: &TestEvent) -> ReplayFrame {
|
||||||
|
let mut frame = ReplayFrame::default();
|
||||||
|
match &event.kind {
|
||||||
|
TestEventKind::ToolChanged { tool } => {
|
||||||
|
frame.tool_change = Some(tool.clone());
|
||||||
|
}
|
||||||
|
other => {
|
||||||
|
frame.synthetic_input = event_kind_to_synthetic(other);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a mouse event kind into a SyntheticInput for the stage pane
|
||||||
|
fn event_kind_to_synthetic(kind: &TestEventKind) -> Option<SyntheticInput> {
|
||||||
|
match kind {
|
||||||
|
TestEventKind::MouseDown { pos } => Some(SyntheticInput {
|
||||||
|
world_pos: Point::new(pos.x, pos.y),
|
||||||
|
drag_started: true,
|
||||||
|
dragged: true, // In egui, drag_started() implies dragged() on the same frame
|
||||||
|
drag_stopped: false,
|
||||||
|
primary_down: true,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
}),
|
||||||
|
TestEventKind::MouseDrag { pos } => Some(SyntheticInput {
|
||||||
|
world_pos: Point::new(pos.x, pos.y),
|
||||||
|
drag_started: false,
|
||||||
|
dragged: true,
|
||||||
|
drag_stopped: false,
|
||||||
|
primary_down: true,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
}),
|
||||||
|
TestEventKind::MouseUp { pos } => Some(SyntheticInput {
|
||||||
|
world_pos: Point::new(pos.x, pos.y),
|
||||||
|
drag_started: false,
|
||||||
|
dragged: true, // In egui, dragged() is still true on the release frame
|
||||||
|
drag_stopped: true,
|
||||||
|
primary_down: false,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
}),
|
||||||
|
TestEventKind::MouseMove { pos } => Some(SyntheticInput {
|
||||||
|
world_pos: Point::new(pos.x, pos.y),
|
||||||
|
drag_started: false,
|
||||||
|
dragged: false,
|
||||||
|
drag_stopped: false,
|
||||||
|
primary_down: false,
|
||||||
|
shift: false,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
}),
|
||||||
|
// Non-mouse events don't produce synthetic input (handled elsewhere)
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns a discriminant for "batchable" mouse motion event types.
|
||||||
|
/// Same-discriminant events are collapsed in the event list display
|
||||||
|
/// and replayed as a single batch when stepping.
|
||||||
|
/// Returns None for non-batchable events (clicks, tool changes, actions, etc.)
|
||||||
|
fn move_discriminant(kind: &TestEventKind) -> Option<u8> {
|
||||||
|
match kind {
|
||||||
|
TestEventKind::MouseMove { .. } => Some(0),
|
||||||
|
TestEventKind::MouseDrag { .. } => Some(1),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a string for use as a filename
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| if c.is_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a tool name (from Debug format) back into a Tool enum value
|
||||||
|
pub fn parse_tool(name: &str) -> Option<lightningbeam_core::tool::Tool> {
|
||||||
|
use lightningbeam_core::tool::Tool;
|
||||||
|
match name {
|
||||||
|
"Select" => Some(Tool::Select),
|
||||||
|
"Draw" => Some(Tool::Draw),
|
||||||
|
"Transform" => Some(Tool::Transform),
|
||||||
|
"Rectangle" => Some(Tool::Rectangle),
|
||||||
|
"Ellipse" => Some(Tool::Ellipse),
|
||||||
|
"PaintBucket" => Some(Tool::PaintBucket),
|
||||||
|
"Eyedropper" => Some(Tool::Eyedropper),
|
||||||
|
"Line" => Some(Tool::Line),
|
||||||
|
"Polygon" => Some(Tool::Polygon),
|
||||||
|
"BezierEdit" => Some(Tool::BezierEdit),
|
||||||
|
"Text" => Some(Tool::Text),
|
||||||
|
"RegionSelect" => Some(Tool::RegionSelect),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Format current time as a compact timestamp (no chrono dependency in editor crate)
|
||||||
|
fn format_timestamp() -> String {
|
||||||
|
use std::time::SystemTime;
|
||||||
|
let duration = SystemTime::now()
|
||||||
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let secs = duration.as_secs();
|
||||||
|
// Simple but unique timestamp: seconds since epoch
|
||||||
|
// For human-readable format we'd need chrono, but this is fine for filenames
|
||||||
|
format!("{}", secs)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue