cranpose 0.1.162

Cranpose runtime and UI facade
Documentation
//! Input event recorder for generating robot tests from manual interactions.
//!
//! This module captures mouse and keyboard events with precise timestamps,
//! then generates Rust robot test code that can replay the exact interaction.
//!
//! # Example
//!
//! ```no_run
//! use cranpose::AppLauncher;
//!
//! AppLauncher::new()
//!     .with_recording(".cranpose-tmp/my_test.rs")
//!     .run(|| {
//!         // Your app - interact with it, then close
//!     });
//! // Generated test file will be at .cranpose-tmp/my_test.rs
//! ```
//!
//! # Data-Driven Tests
//!
//! Generated tests use the [`RobotAction`] enum and [`execute_actions`] function
//! for compact, data-driven test representation:
//!
//! ```ignore
//! use cranpose::recorder::{RobotAction, execute_actions};
//!
//! let actions = [
//!     RobotAction::Sleep(100),
//!     RobotAction::MouseMove(100.0, 200.0),
//!     RobotAction::MouseDown,
//!     RobotAction::Sleep(50),
//!     RobotAction::MouseUp,
//! ];
//! execute_actions(&robot, &actions);
//! ```

use std::{
    io::Write,
    path::PathBuf,
    time::{Instant, SystemTime, UNIX_EPOCH},
};

/// An action that can be executed by a robot test.
///
/// This enum represents all possible actions in a robot test sequence,
/// making tests more compact and data-driven compared to verbose inline code.
#[derive(Debug, Clone, PartialEq)]
pub enum RobotAction {
    /// Sleep for the specified number of milliseconds
    Sleep(u64),
    /// Move the mouse to the specified coordinates
    MouseMove(f32, f32),
    /// Press the mouse button down
    MouseDown,
    /// Release the mouse button
    MouseUp,
    /// Send a key press (press + release)
    Key(String),
}

/// Execute a sequence of robot actions.
///
/// This function takes a reference to a Robot and a slice of actions,
/// executing them in order. This is the runtime counterpart to the
/// data-driven test format generated by the recorder.
///
/// # Example
///
/// ```ignore
/// let actions = [
///     RobotAction::Sleep(100),
///     RobotAction::MouseMove(400.0, 300.0),
///     RobotAction::MouseDown,
///     RobotAction::Sleep(50),
///     RobotAction::MouseUp,
/// ];
/// execute_actions(&robot, &actions);
/// ```
#[cfg(feature = "robot")]
pub fn execute_actions(robot: &crate::Robot, actions: &[RobotAction]) {
    for action in actions {
        match action {
            RobotAction::Sleep(ms) => {
                std::thread::sleep(std::time::Duration::from_millis(*ms));
            }
            RobotAction::MouseMove(x, y) => {
                let _ = robot.mouse_move(*x, *y);
            }
            RobotAction::MouseDown => {
                let _ = robot.mouse_down();
            }
            RobotAction::MouseUp => {
                let _ = robot.mouse_up();
            }
            RobotAction::Key(key) => {
                let _ = robot.send_key(key);
            }
        }
    }
}

/// A recorded input event with timestamp
#[derive(Debug, Clone)]
pub enum RecordedEvent {
    /// Mouse cursor moved
    MouseMove {
        /// Timestamp in milliseconds since recording started
        time_ms: u64,
        /// X coordinate in logical pixels
        x: f32,
        /// Y coordinate in logical pixels
        y: f32,
    },
    /// Left mouse button pressed
    MouseDown {
        /// Timestamp in milliseconds since recording started
        time_ms: u64,
    },
    /// Left mouse button released
    MouseUp {
        /// Timestamp in milliseconds since recording started
        time_ms: u64,
    },
    /// Key pressed
    KeyDown {
        /// Timestamp in milliseconds since recording started
        time_ms: u64,
        /// Key name
        key: String,
    },
    /// Key released
    KeyUp {
        /// Timestamp in milliseconds since recording started
        time_ms: u64,
        /// Key name
        key: String,
    },
}

/// Input recorder that captures events with timestamps
pub struct InputRecorder {
    start_time: Instant,
    events: Vec<RecordedEvent>,
    output_path: PathBuf,
    last_mouse_pos: Option<(f32, f32)>,
}

impl InputRecorder {
    /// Create a new recorder that will save to the given path
    pub fn new(output_path: impl Into<PathBuf>) -> Self {
        let path = output_path.into();
        eprintln!("[Recorder] Recording started - will save to {path:?}");
        Self {
            start_time: Instant::now(),
            events: Vec::new(),
            output_path: path,
            last_mouse_pos: None,
        }
    }

    fn elapsed_ms(&self) -> u64 {
        self.start_time.elapsed().as_millis() as u64
    }

    /// Record a mouse move event
    pub fn record_mouse_move(&mut self, x: f32, y: f32) {
        if let Some((lx, ly)) = self.last_mouse_pos
            && (x - lx).abs() < 0.5
            && (y - ly).abs() < 0.5
        {
            return;
        }
        self.last_mouse_pos = Some((x, y));
        let time_ms = self.elapsed_ms();
        self.events.push(RecordedEvent::MouseMove { time_ms, x, y });
    }

    /// Record a mouse down event
    pub fn record_mouse_down(&mut self) {
        let time_ms = self.elapsed_ms();
        self.events.push(RecordedEvent::MouseDown { time_ms });
    }

    /// Record a mouse up event
    pub fn record_mouse_up(&mut self) {
        let time_ms = self.elapsed_ms();
        self.events.push(RecordedEvent::MouseUp { time_ms });
    }

    /// Finish recording and generate the robot test file.
    ///
    /// The generated test uses a data-driven format with [`RobotAction`] enum
    /// for compact representation:
    ///
    /// ```ignore
    /// const ACTIONS: &[RobotAction] = &[
    ///     Sleep(100),
    ///     MouseMove(400.0, 300.0),
    ///     MouseDown,
    ///     Sleep(50),
    ///     MouseUp,
    /// ];
    /// ```
    pub fn finish(&self) -> std::io::Result<()> {
        if self.events.is_empty() {
            eprintln!("[Recorder] No events recorded, skipping file generation");
            return Ok(());
        }

        eprintln!(
            "[Recorder] Generating robot test with {} events to {:?}",
            self.events.len(),
            self.output_path
        );

        let mut file = std::fs::File::create(&self.output_path)?;

        let actions = self.events_to_actions();

        writeln!(file, "//! Auto-generated robot test from recording")?;
        writeln!(file, "//! Generated at: {}", generated_timestamp())?;
        writeln!(file, "//! Events: {}", self.events.len())?;
        writeln!(file, "//! Actions: {}", actions.len())?;
        writeln!(file)?;
        writeln!(
            file,
            "use cranpose::recorder::{{RobotAction, execute_actions}};"
        )?;
        writeln!(file, "use cranpose::AppLauncher;")?;
        writeln!(file, "use RobotAction::*;")?;
        writeln!(file, "use std::time::Duration;")?;
        writeln!(file)?;

        writeln!(file, "const ACTIONS: &[RobotAction] = &[")?;
        for action in &actions {
            let action_str = match action {
                RobotAction::Sleep(ms) => format!("    Sleep({ms}),"),
                RobotAction::MouseMove(x, y) => format!("    MouseMove({x:.1}, {y:.1}),"),
                RobotAction::MouseDown => "    MouseDown,".to_string(),
                RobotAction::MouseUp => "    MouseUp,".to_string(),
                RobotAction::Key(key) => format!("    Key(\"{key}\".into()),"),
            };
            writeln!(file, "{action_str}")?;
        }
        writeln!(file, "];")?;
        writeln!(file)?;

        writeln!(file, "fn main() {{")?;
        writeln!(file, "    AppLauncher::new()")?;
        writeln!(file, "        .with_headless(true)")?;
        writeln!(file, "        .with_test_driver(|robot| {{")?;
        writeln!(
            file,
            "            std::thread::sleep(Duration::from_millis(500));"
        )?;
        writeln!(file, "            let _ = robot.wait_for_idle();")?;
        writeln!(file)?;
        writeln!(file, "            execute_actions(&robot, ACTIONS);")?;
        writeln!(file)?;
        writeln!(
            file,
            "            std::thread::sleep(Duration::from_secs(1));"
        )?;
        writeln!(file, "            let _ = robot.exit();")?;
        writeln!(file, "        }})")?;
        writeln!(file, "        .run(|| {{")?;
        writeln!(file, "            // Insert your app's composable here")?;
        writeln!(file, "            // desktop_app::app::combined_app();")?;
        writeln!(file, "        }});")?;
        writeln!(file, "}}")?;

        eprintln!("[Recorder] Robot test saved to {:?}", self.output_path);
        Ok(())
    }

    fn events_to_actions(&self) -> Vec<RobotAction> {
        let mut actions = Vec::new();
        let mut last_time_ms = 0u64;

        for event in &self.events {
            let (time_ms, action) = match event {
                RecordedEvent::MouseMove { time_ms, x, y } => {
                    (*time_ms, Some(RobotAction::MouseMove(*x, *y)))
                }
                RecordedEvent::MouseDown { time_ms } => (*time_ms, Some(RobotAction::MouseDown)),
                RecordedEvent::MouseUp { time_ms } => (*time_ms, Some(RobotAction::MouseUp)),
                RecordedEvent::KeyDown { time_ms, key } => {
                    (*time_ms, Some(RobotAction::Key(key.clone())))
                }
                RecordedEvent::KeyUp { time_ms: _, key: _ } => {
                    continue;
                }
            };

            let delta = time_ms.saturating_sub(last_time_ms);
            if delta > 5 {
                actions.push(RobotAction::Sleep(delta));
            }

            if let Some(action) = action {
                actions.push(action);
            }
            last_time_ms = time_ms;
        }

        actions
    }
}

impl Drop for InputRecorder {
    fn drop(&mut self) {
        if let Err(e) = self.finish() {
            eprintln!("[Recorder] Failed to save recording: {e}");
        }
    }
}

fn generated_timestamp() -> String {
    let duration = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap_or_default();
    format!("unix_ms_{}", duration.as_millis())
}

#[cfg(test)]
#[path = "tests/recorder_tests.rs"]
mod tests;