use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, Paragraph},
Frame,
};
use std::sync::{Arc, Mutex};
#[derive(Clone)]
pub struct LogBuffer {
logs: Arc<Mutex<Vec<String>>>,
max_size: usize,
}
impl LogBuffer {
pub fn new(max_size: usize) -> Self {
Self {
logs: Arc::new(Mutex::new(Vec::new())),
max_size,
}
}
pub fn push(&self, log: String) {
let mut logs = self.logs.lock().unwrap();
logs.push(log);
if logs.len() > self.max_size {
let excess = logs.len() - self.max_size;
logs.drain(0..excess);
}
}
pub fn get_logs(&self) -> Vec<String> {
self.logs.lock().unwrap().clone()
}
#[allow(dead_code)]
pub fn clear(&self) {
self.logs.lock().unwrap().clear();
}
}
pub struct DevModeTui {
log_buffer: LogBuffer,
scroll_offset: usize,
current_mode: String,
running_nodes: Vec<String>,
key_pressed: Option<char>,
dashboard_url: String,
}
impl DevModeTui {
pub fn new(log_buffer: LogBuffer, current_mode: String, running_nodes: Vec<String>, dashboard_url: String) -> Self {
Self {
log_buffer,
scroll_offset: 0,
current_mode,
running_nodes,
key_pressed: None,
dashboard_url,
}
}
pub fn update_status(&mut self, mode: String, running_nodes: Vec<String>) {
self.current_mode = mode;
self.running_nodes = running_nodes;
}
pub 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_instructions(f, horizontal_chunks[0]);
self.draw_logs_sidebar(f, horizontal_chunks[1]);
self.draw_footer(f, vertical_chunks[1]);
}
fn draw_instructions(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let mut text = vec![
Line::from(vec![Span::styled(
"🚀 MECHA10 DEVELOPMENT MODE",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from(vec![
Span::styled("Mode: ", Style::default().add_modifier(Modifier::BOLD)),
Span::styled(
&self.current_mode,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
),
]),
Line::from(""),
];
if !self.running_nodes.is_empty() {
text.push(Line::from(vec![Span::styled(
"Running Nodes:",
Style::default().add_modifier(Modifier::BOLD),
)]));
for node in &self.running_nodes {
text.push(Line::from(vec![
Span::raw(" ✅ "),
Span::styled(node, Style::default().fg(Color::Green)),
]));
}
text.push(Line::from(""));
}
text.extend(vec![
Line::from("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"),
Line::from(""),
Line::from(vec![Span::styled(
"Commands:",
Style::default().add_modifier(Modifier::BOLD),
)]),
]);
let current_mode_lower = self.current_mode.to_lowercase();
if current_mode_lower == "simulation" {
text.push(Line::from(vec![Span::styled(
" x - Stop simulation mode",
Style::default().fg(Color::Yellow),
)]));
} else if current_mode_lower == "teleop" {
text.push(Line::from(" s - Switch to simulation mode"));
} else {
text.push(Line::from(" s - Switch to simulation mode"));
text.push(Line::from(" t - Switch to teleop mode"));
}
text.extend(vec![
Line::from(""),
Line::from(" j/k - Scroll logs (or PgUp/PgDn)"),
Line::from(""),
Line::from(vec![Span::styled(
" Ctrl+C/Ctrl+X - Exit",
Style::default().fg(Color::Red),
)]),
]);
if let Some(key) = self.key_pressed {
text.push(Line::from(""));
text.push(Line::from(vec![
Span::raw("Last key: "),
Span::styled(key.to_string(), Style::default().fg(Color::Yellow)),
]));
}
let paragraph = Paragraph::new(text).block(
Block::default()
.borders(Borders::ALL)
.title(" Dev Mode ")
.border_style(Style::default().fg(Color::Cyan)),
);
f.render_widget(paragraph, area);
}
fn draw_logs_sidebar(&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 log_trimmed = log.trim_start();
let already_has_icon = log_trimmed.starts_with("✅")
|| log_trimmed.starts_with("❌")
|| log_trimmed.starts_with("⚠️")
|| log_trimmed.starts_with("🛑")
|| log_trimmed.starts_with("▶️")
|| log_trimmed.starts_with("🔄");
let formatted_log = if already_has_icon {
format!(" {}", log)
} else {
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(
"Ctrl+C/X:",
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);
}
pub fn handle_key(&mut self, key: KeyEvent) -> Option<char> {
match key.code {
KeyCode::PageUp | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.scroll_offset = self.scroll_offset.saturating_sub(10);
None
}
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));
None
}
KeyCode::Char('k') if key.modifiers.is_empty() => {
self.scroll_offset = self.scroll_offset.saturating_sub(1);
None
}
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));
None
}
KeyCode::Char(c) => {
self.key_pressed = Some(c);
Some(c)
}
_ => None,
}
}
}
#[allow(dead_code)]
pub async fn subscribe_to_logs(_log_buffer: LogBuffer, _redis_url: String) -> Result<()> {
Ok(())
}