lv-tui 0.4.0

A reactive TUI framework for Rust
Documentation
use crate::backend::HeadlessBackend;
use crate::buffer::Buffer;
use crate::component::Component;
use crate::event::{Event, Key, KeyEvent, Modifiers};
use crate::runtime::Runtime;
use crate::Result;

/// 自动化测试驱动器。
///
/// 封装 headless runtime,提供事件注入和渲染输出检查。
///
/// ```rust,ignore
/// let mut pilot = App::test(MyRoot::new(), 80, 24);
/// pilot.press(Key::Char(' '));       // 按空格键
/// pilot.press(Key::Tab);             // 按 Tab
/// assert!(pilot.frame().cells.iter().any(|c| c.symbol == "✓"));
/// ```
pub struct Pilot {
    runtime: Runtime<HeadlessBackend>,
}

impl Pilot {
    /// Creates a new test pilot with the given root component and terminal
    /// dimensions. Runs initial layout, mount lifecycle, and first paint.
    pub fn new(root: impl Component + 'static, width: u16, height: u16) -> Self {
        let backend = HeadlessBackend::new(width, height);
        let mut runtime = Runtime::new(root, backend)
            .expect("headless runtime creation")
            .tick_rate(std::time::Duration::from_millis(10)); // fast poll for tests
        runtime.initial_layout_and_paint().expect("initial layout/paint");
        runtime.process_timer_requests();
        Self { runtime }
    }

    /// Injects a key press event and runs one event-loop iteration.
    pub fn press(&mut self, key: Key) -> Result<()> {
        self.send_event(Event::Key(KeyEvent {
            key,
            modifiers: Modifiers::default(),
        }))
    }

    /// Injects a key press with modifiers and runs one event-loop iteration.
    pub fn press_with_modifiers(&mut self, key: Key, modifiers: Modifiers) -> Result<()> {
        self.send_event(Event::Key(KeyEvent { key, modifiers }))
    }

    /// Injects an arbitrary event and runs one event-loop iteration.
    pub fn send_event(&mut self, event: Event) -> Result<()> {
        self.runtime.backend.push_event(event);
        self.runtime.step()
    }

    /// Injects multiple events, running one iteration per event.
    pub fn send_events(&mut self, events: impl IntoIterator<Item = Event>) -> Result<()> {
        for event in events {
            self.send_event(event)?;
        }
        Ok(())
    }

    /// Runs event-loop iterations until the application quits or `max_steps`
    /// is reached. Returns `true` if the app quit.
    pub fn run_until_quit(&mut self, max_steps: usize) -> Result<bool> {
        for _ in 0..max_steps {
            if self.runtime.quit {
                return Ok(true);
            }
            self.runtime.step()?;
        }
        Ok(self.runtime.quit)
    }

    /// Runs event-loop iterations until `condition` returns `true` or
    /// `max_steps` is reached. The condition receives a reference to the
    /// current front buffer.
    pub fn run_until(
        &mut self,
        max_steps: usize,
        mut condition: impl FnMut(&Buffer) -> bool,
    ) -> Result<bool> {
        for _ in 0..max_steps {
            if condition(&self.runtime.front) {
                return Ok(true);
            }
            self.runtime.step()?;
        }
        Ok(condition(&self.runtime.front))
    }

    /// Returns a reference to the current rendered front buffer.
    pub fn frame(&self) -> &Buffer {
        &self.runtime.front
    }

    /// Returns whether the application has quit.
    pub fn has_quit(&self) -> bool {
        self.runtime.quit
    }

    /// Triggers focus navigation (like pressing Tab) to set focus to the
    /// first focusable component. This properly dispatches Focus/Blur events
    /// through the component tree and repaints.
    pub fn focus_first(&mut self) {
        self.runtime.focus_next(false);
        // focus_next sets dirty |= PAINT; trigger paint immediately
        let _ = self.runtime.paint_frame();
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::event::EventPhase;
    use crate::render::RenderCx;
    use crate::style::Style;
    use crate::widgets::Label;

    struct TestApp {
        label_text: String,
    }

    impl TestApp {
        fn new(text: &str) -> Self {
            Self {
                label_text: text.to_string(),
            }
        }
    }

    impl Component for TestApp {
        fn render(&self, cx: &mut RenderCx) {
            cx.line(&self.label_text);
        }

        fn event(&mut self, event: &Event, cx: &mut crate::component::EventCx) {
            if cx.phase() != EventPhase::Target {
                return;
            }
            if event.is_key(Key::Char('q')) {
                cx.quit();
            }
        }
    }

    #[test]
    fn test_pilot_basic_render() {
        let pilot = Pilot::new(TestApp::new("hello"), 10, 3);
        assert_eq!(&pilot.frame().cells[0].symbol, "h");
    }

    #[test]
    fn test_pilot_press_key() {
        let mut pilot = Pilot::new(TestApp::new("test"), 10, 3);
        assert!(!pilot.has_quit());
        pilot.press(Key::Char('q')).unwrap();
        assert!(pilot.has_quit());
    }

    #[test]
    fn test_pilot_label_renders() {
        let pilot = Pilot::new(Label::new("Hello World").style(Style::default()), 20, 3);
        assert!(pilot.frame().cells.iter().any(|c| c.symbol == "H"));
    }

    #[test]
    fn test_pilot_run_until_quit() {
        let mut pilot = Pilot::new(TestApp::new("x"), 10, 3);
        pilot.press(Key::Char('q')).unwrap();
        // After quit, run_until_quit should return immediately
        let quit = pilot.run_until_quit(10).unwrap();
        assert!(quit);
    }

    #[test]
    fn test_pilot_run_until() {
        let mut pilot = Pilot::new(TestApp::new("data"), 10, 3);
        let found = pilot
            .run_until(10, |buf| buf.cells.iter().any(|c| c.symbol == "d"))
            .unwrap();
        assert!(found);
    }

    #[test]
    fn test_pilot_input_form() {
        use crate::widgets::Checkbox;

        let mut pilot = Pilot::new(Checkbox::new("Option"), 20, 3);

        // Initial state: unchecked
        let has_check = pilot.frame().cells.iter().any(|c| c.symbol == " ");
        assert!(has_check, "should show unchecked marker");

        // Press space to toggle
        pilot.press(Key::Char(' ')).unwrap();

        // After toggle: should show checkmark
        let has_check = pilot.frame().cells.iter().any(|c| c.symbol == "");
        assert!(has_check, "should show checked marker after toggle");
    }
}