mecha10-cli 0.1.47

Mecha10 CLI tool
Documentation
//! Teleop UI with ratatui TUI and logs sidebar
//!
//! Provides a 3-panel interface:
//! - Main area: Teleop controls and status
//! - Sidebar: Real-time system logs
//! - Footer: Quick links to dashboard and docs

use crate::ui::LogBuffer;
use anyhow::Result;
use crossterm::{
    event::{self, Event, KeyCode, KeyEvent, KeyModifiers},
    execute,
    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use mecha10_core::context::Context;
use mecha10_core::prelude::now_micros;
use mecha10_core::teleop::TeleopInput;
use mecha10_core::topics::Topic;
use ratatui::{
    backend::CrosstermBackend,
    layout::{Constraint, Direction, Layout},
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Borders, List, ListItem, Paragraph},
    Frame, Terminal,
};
use std::io::stdout;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};

/// Teleop topics
const TELEOP_CMD: Topic<TeleopInput> = Topic::new("/teleop/cmd");

/// Teleop UI with TUI interface
pub struct TeleopUi {
    redis_url: String,
    log_buffer: LogBuffer,
    max_linear_vel: f32,
    max_angular_vel: f32,
    priority: u8,
    current_linear: f32,
    current_angular: f32,
    command_count: u64,
    last_command: Option<Instant>,
    scroll_offset: usize,
    should_quit: bool,
    dashboard_url: String,
}

impl TeleopUi {
    /// Create a new teleop UI
    ///
    /// # Arguments
    ///
    /// * `redis_url` - Redis connection URL
    /// * `log_buffer` - Shared log buffer for collecting logs
    /// * `dashboard_url` - Dashboard URL to display in footer (derived from mecha10.json)
    pub fn new(redis_url: String, log_buffer: LogBuffer, dashboard_url: String) -> Self {
        Self {
            redis_url,
            log_buffer,
            max_linear_vel: 0.5,
            max_angular_vel: 1.0,
            priority: 50,
            current_linear: 0.0,
            current_angular: 0.0,
            command_count: 0,
            last_command: None,
            scroll_offset: 0,
            should_quit: false,
            dashboard_url,
        }
    }

    /// Run the teleop UI with interrupt support
    pub async fn run_with_interrupt(&mut self, running: Arc<AtomicBool>) -> Result<()> {
        // Set REDIS_URL env var for Context creation
        std::env::set_var("REDIS_URL", &self.redis_url);

        // Create Redis context
        let ctx = Context::new("cli_teleop_tui").await?;

        // Setup terminal
        enable_raw_mode()?;
        let mut stdout = stdout();
        execute!(stdout, EnterAlternateScreen)?;
        let backend = CrosstermBackend::new(stdout);
        let mut terminal = Terminal::new(backend)?;

        // Run main loop
        let result = self.event_loop(&mut terminal, &ctx, &running).await;

        // Send stop command on exit
        self.send_stop(&ctx).await?;

        // Cleanup terminal
        disable_raw_mode()?;
        execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
        terminal.show_cursor()?;

        result
    }

    /// Main event loop
    async fn event_loop(
        &mut self,
        terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
        ctx: &Context,
        running: &Arc<AtomicBool>,
    ) -> Result<()> {
        loop {
            // Check interrupt flag
            if !running.load(Ordering::SeqCst) || self.should_quit {
                break;
            }

            // Draw UI
            terminal.draw(|f| self.draw(f))?;

            // Poll for events
            if event::poll(Duration::from_millis(100))? {
                if let Event::Key(key) = event::read()? {
                    self.handle_key(key, ctx).await?;
                }
            }
        }

        Ok(())
    }

    /// Draw the UI with 3-panel layout: main area, logs sidebar, footer
    fn draw(&mut self, f: &mut Frame) {
        // Split terminal vertically: content area + footer
        let vertical_chunks = Layout::default()
            .direction(Direction::Vertical)
            .constraints([
                Constraint::Min(10),   // Main content area
                Constraint::Length(3), // Footer
            ])
            .split(f.area());

        // Split main content horizontally: teleop controls + logs sidebar
        let horizontal_chunks = Layout::default()
            .direction(Direction::Horizontal)
            .constraints([
                Constraint::Percentage(60), // Main teleop area
                Constraint::Percentage(40), // Logs sidebar
            ])
            .split(vertical_chunks[0]);

        // Draw panels
        self.draw_teleop_panel(f, horizontal_chunks[0]);
        self.draw_logs_panel(f, horizontal_chunks[1]);
        self.draw_footer(f, vertical_chunks[1]);
    }

    /// Draw teleop controls panel
    fn draw_teleop_panel(&self, f: &mut Frame, area: ratatui::layout::Rect) {
        let status_color = if self.current_linear == 0.0 && self.current_angular == 0.0 {
            Color::Gray
        } else {
            Color::Green
        };

        let text = vec![
            Line::from(vec![Span::styled(
                "🎮 TELEOPERATION CONTROL",
                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
            )]),
            Line::from(""),
            Line::from("Controls:"),
            Line::from("  ↑ / W       Forward"),
            Line::from("  ↓ / S       Backward"),
            Line::from("  ← / A       Turn Left"),
            Line::from("  → / D       Turn Right"),
            Line::from("  SPACE / X   Stop"),
            Line::from("  E           Emergency Stop"),
            Line::from(""),
            Line::from("Navigation:"),
            Line::from("  j/k         Scroll logs (or PgUp/PgDn)"),
            Line::from(""),
            Line::from(vec![Span::styled(
                "  Q / ESC     Exit",
                Style::default().fg(Color::Yellow),
            )]),
            Line::from(""),
            Line::from("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"),
            Line::from(""),
            Line::from(vec![Span::styled(
                "Status:",
                Style::default().add_modifier(Modifier::BOLD),
            )]),
            Line::from(vec![
                Span::raw("  Linear:  "),
                Span::styled(format!("{:.2}", self.current_linear), Style::default().fg(status_color)),
                Span::raw(" m/s"),
            ]),
            Line::from(vec![
                Span::raw("  Angular: "),
                Span::styled(
                    format!("{:.2}", self.current_angular),
                    Style::default().fg(status_color),
                ),
                Span::raw(" rad/s"),
            ]),
            Line::from(""),
            Line::from(vec![
                Span::raw("  Commands: "),
                Span::styled(self.command_count.to_string(), Style::default().fg(Color::Cyan)),
            ]),
        ];

        let paragraph = Paragraph::new(text).block(
            Block::default()
                .borders(Borders::ALL)
                .title(" Teleop Controls ")
                .border_style(Style::default().fg(Color::Cyan)),
        );

        f.render_widget(paragraph, area);
    }

    /// Draw logs sidebar with mode changes, node status, and general logs
    fn draw_logs_panel(&self, f: &mut Frame, area: ratatui::layout::Rect) {
        let logs = self.log_buffer.get_logs();

        // Convert logs to list items with color coding and icons
        let items: Vec<ListItem> = logs
            .iter()
            .skip(self.scroll_offset)
            .map(|log| {
                let (style, icon) = if log.contains("ERROR") {
                    (Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), "")
                } else if log.contains("WARN") {
                    (Style::default().fg(Color::Yellow), "⚠️ ")
                } else if log.contains("MODE:") {
                    (Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "")
                } else if log.contains("STARTED") || log.contains("") {
                    (Style::default().fg(Color::Green), "")
                } else if log.contains("STOPPED") || log.contains("🛑") {
                    (Style::default().fg(Color::Red), "🛑")
                } else if log.contains("INFO") {
                    (Style::default().fg(Color::Green), "ℹ️ ")
                } else if log.contains("DEBUG") {
                    (Style::default().fg(Color::Blue), "🔍")
                } else {
                    (Style::default().fg(Color::White), "  ")
                };

                let formatted_log = format!("{} {}", icon, log);
                ListItem::new(formatted_log).style(style)
            })
            .collect();

        let title = if logs.is_empty() {
            " System Logs (waiting...) ".to_string()
        } else {
            format!(" System Logs ({}) ", logs.len())
        };

        let list = List::new(items).block(
            Block::default()
                .borders(Borders::ALL)
                .title(title)
                .border_style(Style::default().fg(Color::Yellow)),
        );

        f.render_widget(list, area);
    }

    /// Draw footer with links to dashboard and documentation
    fn draw_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) {
        let footer_text = vec![Line::from(vec![
            Span::raw("  "),
            Span::styled(
                "Dashboard:",
                Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
            ),
            Span::raw(format!(" {}  ", self.dashboard_url)),
            Span::styled("Docs:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
            Span::raw(" https://mecha10.dev  "),
            Span::styled("Q/ESC:", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
            Span::raw(" Exit"),
        ])];

        let footer = Paragraph::new(footer_text).block(
            Block::default()
                .borders(Borders::ALL)
                .border_style(Style::default().fg(Color::DarkGray)),
        );

        f.render_widget(footer, area);
    }

    /// Handle keyboard input
    async fn handle_key(&mut self, key: KeyEvent, ctx: &Context) -> Result<()> {
        match key.code {
            // Exit (check Ctrl+C first before movement controls)
            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.should_quit = true;
            }
            KeyCode::Char('q') | KeyCode::Esc => {
                self.should_quit = true;
            }

            // Scroll logs with modifiers (check before movement controls)
            KeyCode::PageUp | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                self.scroll_offset = self.scroll_offset.saturating_sub(10);
            }
            KeyCode::PageDown | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                let logs = self.log_buffer.get_logs();
                self.scroll_offset = (self.scroll_offset + 10).min(logs.len().saturating_sub(1));
            }

            // Scroll logs single-line (j/k) - check before other char matches
            KeyCode::Char('k') if key.modifiers.is_empty() => {
                self.scroll_offset = self.scroll_offset.saturating_sub(1);
            }
            KeyCode::Char('j') if key.modifiers.is_empty() => {
                let logs = self.log_buffer.get_logs();
                self.scroll_offset = (self.scroll_offset + 1).min(logs.len().saturating_sub(1));
            }

            // Movement controls (only when no modifiers)
            KeyCode::Char('w') | KeyCode::Up if key.modifiers.is_empty() || key.code == KeyCode::Up => {
                self.current_linear = self.max_linear_vel;
                self.current_angular = 0.0;
                self.send_velocity(ctx).await?;
            }
            KeyCode::Char('s') | KeyCode::Down if key.modifiers.is_empty() || key.code == KeyCode::Down => {
                self.current_linear = -self.max_linear_vel;
                self.current_angular = 0.0;
                self.send_velocity(ctx).await?;
            }
            KeyCode::Char('a') | KeyCode::Left if key.modifiers.is_empty() || key.code == KeyCode::Left => {
                self.current_linear = 0.0;
                self.current_angular = self.max_angular_vel;
                self.send_velocity(ctx).await?;
            }
            KeyCode::Char('d') | KeyCode::Right if key.modifiers.is_empty() || key.code == KeyCode::Right => {
                self.current_linear = 0.0;
                self.current_angular = -self.max_angular_vel;
                self.send_velocity(ctx).await?;
            }

            // Stop
            KeyCode::Char(' ') | KeyCode::Char('x') if key.modifiers.is_empty() => {
                self.current_linear = 0.0;
                self.current_angular = 0.0;
                self.send_velocity(ctx).await?;
            }

            // Emergency stop
            KeyCode::Char('e') if key.modifiers.is_empty() => {
                self.send_emergency_stop(ctx).await?;
            }

            _ => {}
        }

        Ok(())
    }

    /// Send velocity command
    async fn send_velocity(&mut self, ctx: &Context) -> Result<()> {
        let input = TeleopInput::new("cli_tui", self.current_linear, self.current_angular, now_micros())
            .with_priority(self.priority);

        ctx.publish_to(TELEOP_CMD, &input).await?;
        self.last_command = Some(Instant::now());
        self.command_count += 1;

        Ok(())
    }

    /// Send stop command
    async fn send_stop(&mut self, ctx: &Context) -> Result<()> {
        let input = TeleopInput::new("cli_tui", 0.0, 0.0, now_micros()).with_priority(self.priority);

        ctx.publish_to(TELEOP_CMD, &input).await?;
        Ok(())
    }

    /// Send emergency stop
    async fn send_emergency_stop(&mut self, ctx: &Context) -> Result<()> {
        let input = TeleopInput::emergency_stop("cli_tui", now_micros());

        ctx.publish_to(TELEOP_CMD, &input).await?;
        self.current_linear = 0.0;
        self.current_angular = 0.0;
        self.last_command = Some(Instant::now());
        self.command_count += 1;

        Ok(())
    }
}