o7 0.1.1

O7 workflow DSL runner
Documentation
//! TUI Event Loop — merges crossterm keyboard events, engine events, and tick timer.
//!
//! Uses `tokio::select!` to concurrently wait on:
//! - Crossterm keyboard/terminal events (via `EventStream`)
//! - Engine execution events arriving via an `mpsc::Receiver`
//! - A periodic tick interval for spinner animation and elapsed time
//! - Q&A file polling for interactive question handling

use std::io::Stdout;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;

use crossterm::event::{Event as CtEvent, EventStream, KeyEventKind};
use futures::StreamExt;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use tokio::sync::mpsc;

use super::app::{App, AppMessage};
use super::qa_watcher::QAWatcher;
use super::ui::draw;

/// Tick interval for spinner animation and time updates.
const TICK_RATE: Duration = Duration::from_millis(100);

/// Callback type for handling QA submit (writing answer files).
pub type QASubmitCallback = Box<dyn FnMut(u32, &super::qa_protocol::QAnswersFile) + Send>;

/// Run the main event loop.
///
/// Merges crossterm events, engine events, tick timer, and Q&A file polling.
/// Renders the UI each tick and when events arrive. Returns when the user
/// quits or the terminal is lost.
pub async fn run_event_loop(
    terminal: &mut Terminal<CrosstermBackend<Stdout>>,
    app: &mut App,
    engine_rx: mpsc::UnboundedReceiver<AppMessage>,
    mut qa_watcher: Option<QAWatcher>,
    mut on_qa_submit: Option<QASubmitCallback>,
    run_id_holder: Arc<std::sync::RwLock<Option<String>>>,
    project_root: String,
) -> std::io::Result<()> {
    let mut reader = EventStream::new();
    let mut tick_interval = tokio::time::interval(TICK_RATE);
    let mut qa_poll_interval = tokio::time::interval(Duration::from_millis(500));
    let mut engine_rx = Some(engine_rx);

    // Initial draw.
    terminal.draw(|frame| draw(frame, app))?;

    loop {
        // Lazily initialize the QA watcher once the run_id is known.
        if qa_watcher.is_none()
            && let Ok(guard) = run_id_holder.read()
            && let Some(ref run_id) = *guard
        {
            let run_state_dir = PathBuf::from(format!("{}/.7/runs/{}", project_root, run_id));
            let watcher = QAWatcher::new(run_state_dir);
            let _ = watcher.ensure_qa_dir();
            qa_watcher = Some(watcher);
        }

        tokio::select! {
            // Crossterm terminal events (keyboard, resize, etc.).
            maybe_event = reader.next() => {
                match maybe_event {
                    Some(Ok(CtEvent::Key(key))) => {
                        // Only handle Press events (not Release/Repeat) to avoid double-firing.
                        if key.kind == KeyEventKind::Press {
                            app.update(AppMessage::KeyPress(key));
                        }
                    }
                    Some(Ok(CtEvent::Resize(_, _))) => {
                        // Terminal resized — just redraw.
                    }
                    Some(Err(_)) => {
                        // Terminal event error — exit gracefully.
                        break;
                    }
                    None => {
                        // Stream ended — terminal disconnected.
                        break;
                    }
                    _ => {
                        // Mouse or other events — ignore.
                    }
                }
            }

            // Engine events arriving via channel (only poll when receiver is live).
            Some(maybe_msg) = async {
                match engine_rx.as_mut() {
                    Some(rx) => Some(rx.recv().await),
                    None => None,
                }
            } => {
                match maybe_msg {
                    Some(msg) => {
                        app.update(msg);
                    }
                    None => {
                        // Channel closed — engine is done. Stop polling.
                        engine_rx = None;
                        app.update(AppMessage::EngineFinished);
                    }
                }
            }

            // Q&A file polling (only when watcher is active).
            _ = qa_poll_interval.tick(), if qa_watcher.is_some() => {
                if let Some(ref mut watcher) = qa_watcher
                    && let Some((seq, qout)) = watcher.poll_for_questions()
                {
                    app.update(AppMessage::QuestionsDetected(seq, qout));
                }
            }

            // Periodic tick for spinner animation and elapsed time.
            _ = tick_interval.tick() => {
                app.update(AppMessage::Tick);
            }
        }

        // Handle QA submit: write answer file and mark sequence as answered.
        if app.qa_submit_pending {
            app.qa_submit_pending = false;
            if let Some((seq, answers_file)) = app.take_qa_submit_data() {
                if let Some(ref mut callback) = on_qa_submit {
                    callback(seq, &answers_file);
                }
                if let Some(ref mut watcher) = qa_watcher {
                    watcher.mark_answered(seq);
                }
            }
        }

        // Render after every event.
        terminal.draw(|frame| draw(frame, app))?;

        if app.should_quit {
            break;
        }
    }

    Ok(())
}