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};
const TELEOP_CMD: Topic<TeleopInput> = Topic::new("/teleop/cmd");
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 {
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,
}
}
pub async fn run_with_interrupt(&mut self, running: Arc<AtomicBool>) -> Result<()> {
std::env::set_var("REDIS_URL", &self.redis_url);
let ctx = Context::new("cli_teleop_tui").await?;
enable_raw_mode()?;
let mut stdout = stdout();
execute!(stdout, EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let result = self.event_loop(&mut terminal, &ctx, &running).await;
self.send_stop(&ctx).await?;
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
terminal.show_cursor()?;
result
}
async fn event_loop(
&mut self,
terminal: &mut Terminal<CrosstermBackend<std::io::Stdout>>,
ctx: &Context,
running: &Arc<AtomicBool>,
) -> Result<()> {
loop {
if !running.load(Ordering::SeqCst) || self.should_quit {
break;
}
terminal.draw(|f| self.draw(f))?;
if event::poll(Duration::from_millis(100))? {
if let Event::Key(key) = event::read()? {
self.handle_key(key, ctx).await?;
}
}
}
Ok(())
}
fn draw(&mut self, f: &mut Frame) {
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Min(10), Constraint::Length(3), ])
.split(f.area());
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage(60), Constraint::Percentage(40), ])
.split(vertical_chunks[0]);
self.draw_teleop_panel(f, horizontal_chunks[0]);
self.draw_logs_panel(f, horizontal_chunks[1]);
self.draw_footer(f, vertical_chunks[1]);
}
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);
}
fn draw_logs_panel(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let logs = self.log_buffer.get_logs();
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);
}
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);
}
async fn handle_key(&mut self, key: KeyEvent, ctx: &Context) -> Result<()> {
match key.code {
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
KeyCode::Char('q') | KeyCode::Esc => {
self.should_quit = true;
}
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));
}
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));
}
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?;
}
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?;
}
KeyCode::Char('e') if key.modifiers.is_empty() => {
self.send_emergency_stop(ctx).await?;
}
_ => {}
}
Ok(())
}
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(())
}
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(())
}
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(())
}
}