use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
layout::{Constraint, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
Frame,
};
use serde_json::Value;
use std::sync::{Arc, Mutex};
#[derive(Clone, Debug)]
pub struct TopicMessage {
pub entry_id: String, pub timestamp: String, pub payload: Value,
}
#[derive(Clone)]
pub struct MessageBuffer {
messages: Arc<Mutex<Vec<TopicMessage>>>,
max_size: usize,
}
impl MessageBuffer {
pub fn new(max_size: usize) -> Self {
Self {
messages: Arc::new(Mutex::new(Vec::new())),
max_size,
}
}
pub fn push(&self, message: TopicMessage) {
let mut messages = self.messages.lock().unwrap();
messages.push(message);
if messages.len() > self.max_size {
let excess = messages.len() - self.max_size;
messages.drain(0..excess);
}
}
pub fn get_messages(&self) -> Vec<TopicMessage> {
self.messages.lock().unwrap().clone()
}
pub fn clear(&self) {
self.messages.lock().unwrap().clear();
}
}
pub struct TopicsListTui {
topics: Vec<String>,
selected_index: usize,
monitoring_topic: Option<String>,
message_buffer: MessageBuffer,
message_scroll_offset: usize,
auto_scroll: bool, should_quit: bool,
}
impl TopicsListTui {
pub fn new(topics: Vec<String>) -> Self {
Self {
topics,
selected_index: 0,
monitoring_topic: None,
message_buffer: MessageBuffer::new(1000),
message_scroll_offset: 0,
auto_scroll: true, should_quit: false,
}
}
pub fn selected_topic(&self) -> Option<&str> {
self.topics.get(self.selected_index).map(|s| s.as_str())
}
pub fn monitoring_topic(&self) -> Option<&str> {
self.monitoring_topic.as_deref()
}
pub fn start_monitoring(&mut self) {
if let Some(topic) = self.selected_topic() {
self.monitoring_topic = Some(topic.to_string());
self.message_buffer.clear();
self.message_scroll_offset = 0;
self.auto_scroll = true; }
}
pub fn stop_monitoring(&mut self) {
self.monitoring_topic = None;
}
pub fn message_buffer(&self) -> MessageBuffer {
self.message_buffer.clone()
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn update_topics(&mut self, new_topics: Vec<String>) {
let currently_selected = self.selected_topic().map(|s| s.to_string());
self.topics = new_topics;
if let Some(selected) = currently_selected {
if let Some(index) = self.topics.iter().position(|t| t == &selected) {
self.selected_index = index;
} else {
self.selected_index = 0;
}
} else {
if self.selected_index >= self.topics.len() {
self.selected_index = self.topics.len().saturating_sub(1);
}
}
}
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(40), Constraint::Percentage(60), ])
.split(vertical_chunks[0]);
self.draw_topics_panel(f, horizontal_chunks[0]);
self.draw_messages_panel(f, horizontal_chunks[1]);
self.draw_footer(f, vertical_chunks[1]);
}
fn draw_topics_panel(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
let items: Vec<ListItem> = self
.topics
.iter()
.enumerate()
.map(|(i, topic)| {
let is_selected = i == self.selected_index;
let is_monitoring = self.monitoring_topic.as_ref().map(|t| t == topic).unwrap_or(false);
let (icon, style) = if is_monitoring {
("🔴", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD))
} else if is_selected {
("▶ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
} else {
(" ", Style::default().fg(Color::White))
};
let formatted = format!("{} {}", icon, topic);
ListItem::new(formatted).style(style)
})
.collect();
let title = format!(" Topics ({}) ", self.topics.len());
let list = List::new(items).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Cyan)),
);
let mut state = ListState::default();
state.select(Some(self.selected_index));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_messages_panel(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let messages = self.message_buffer.get_messages();
if let Some(topic) = &self.monitoring_topic {
let available_height = area.height.saturating_sub(2) as usize;
let messages_to_show: Vec<ListItem> = if self.auto_scroll {
messages
.iter()
.rev()
.take(available_height)
.rev()
.map(|msg| {
let payload_str =
serde_json::to_string_pretty(&msg.payload).unwrap_or_else(|_| msg.payload.to_string());
let formatted = format!("[{}] {}", msg.timestamp, payload_str);
ListItem::new(formatted).style(Style::default().fg(Color::White))
})
.collect()
} else {
messages
.iter()
.skip(self.message_scroll_offset)
.take(available_height)
.map(|msg| {
let payload_str =
serde_json::to_string_pretty(&msg.payload).unwrap_or_else(|_| msg.payload.to_string());
let formatted = format!("[{}] {}", msg.timestamp, payload_str);
ListItem::new(formatted).style(Style::default().fg(Color::White))
})
.collect()
};
let scroll_indicator = if self.auto_scroll {
" [Auto-scroll] "
} else {
" [Manual] "
};
let title = format!(" Messages from {} ({}) {} ", topic, messages.len(), scroll_indicator);
let list = List::new(messages_to_show).block(
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(Color::Yellow)),
);
f.render_widget(list, area);
} else {
let text = vec![
Line::from(vec![Span::styled(
"📋 TOPICS MONITOR",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
Line::from("Select a topic from the list and press"),
Line::from("Enter to start monitoring."),
Line::from(""),
Line::from("Messages will appear here in real-time."),
Line::from(""),
Line::from(vec![Span::styled(
"Instructions:",
Style::default().add_modifier(Modifier::BOLD),
)]),
Line::from(" ↑/↓ Navigate topics"),
Line::from(" Enter Start/stop monitoring"),
Line::from(" j/k Scroll messages"),
Line::from(" g/G Jump to top/bottom"),
Line::from(" Q/ESC Exit"),
];
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(" Messages ")
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
}
fn draw_footer(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let footer_text = vec![Line::from(vec![
Span::raw(" "),
Span::styled("↑/↓:", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw(" Navigate "),
Span::styled("j/k:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(" Scroll "),
Span::styled("g/G:", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
Span::raw(" Top/Bottom "),
Span::styled("Enter:", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
Span::raw(" Monitor "),
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);
}
pub fn handle_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Char('q') | KeyCode::Esc => {
self.should_quit = true;
}
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.should_quit = true;
}
KeyCode::Up => {
if self.selected_index > 0 {
self.selected_index -= 1;
}
}
KeyCode::Down => {
if self.selected_index < self.topics.len().saturating_sub(1) {
self.selected_index += 1;
}
}
KeyCode::Enter => {
if self.monitoring_topic.is_some() {
self.stop_monitoring();
} else {
self.start_monitoring();
}
}
KeyCode::PageUp | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.auto_scroll = false; self.message_scroll_offset = self.message_scroll_offset.saturating_sub(10);
}
KeyCode::PageDown | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let messages = self.message_buffer.get_messages();
let new_offset = (self.message_scroll_offset + 10).min(messages.len().saturating_sub(1));
if new_offset >= messages.len().saturating_sub(1) {
self.auto_scroll = true;
} else {
self.auto_scroll = false;
self.message_scroll_offset = new_offset;
}
}
KeyCode::Char('k') => {
self.auto_scroll = false; self.message_scroll_offset = self.message_scroll_offset.saturating_sub(1);
}
KeyCode::Char('j') => {
let messages = self.message_buffer.get_messages();
let new_offset = (self.message_scroll_offset + 1).min(messages.len().saturating_sub(1));
if new_offset >= messages.len().saturating_sub(1) {
self.auto_scroll = true;
} else {
self.auto_scroll = false;
self.message_scroll_offset = new_offset;
}
}
KeyCode::Char('G') => {
self.auto_scroll = true;
self.message_scroll_offset = 0;
}
KeyCode::Char('g') => {
self.auto_scroll = false;
self.message_scroll_offset = 0;
}
_ => {}
}
}
}