use crate::services::Topology;
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,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Section {
Services,
Nodes,
Topics,
}
impl Section {
fn all() -> Vec<Section> {
vec![Section::Services, Section::Nodes, Section::Topics]
}
fn label(&self) -> &str {
match self {
Section::Services => "Services",
Section::Nodes => "Nodes",
Section::Topics => "Topics",
}
}
fn icon(&self) -> &str {
match self {
Section::Services => "🌐",
Section::Nodes => "🔌",
Section::Topics => "📨",
}
}
}
pub struct TopologyTui {
topology: Topology,
current_section: Section,
detail_scroll_offset: usize,
should_quit: bool,
}
impl TopologyTui {
pub fn new(topology: Topology) -> Self {
Self {
topology,
current_section: Section::Services,
detail_scroll_offset: 0,
should_quit: false,
}
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
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(30), Constraint::Percentage(70), ])
.split(vertical_chunks[0]);
self.draw_navigation(f, horizontal_chunks[0]);
self.draw_details(f, horizontal_chunks[1]);
self.draw_footer(f, vertical_chunks[1]);
}
fn draw_navigation(&mut self, f: &mut Frame, area: ratatui::layout::Rect) {
let sections = Section::all();
let items: Vec<ListItem> = sections
.iter()
.map(|section| {
let is_selected = *section == self.current_section;
let (prefix, style) = if is_selected {
("▶ ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD))
} else {
(" ", Style::default().fg(Color::White))
};
let formatted = format!("{} {} {}", prefix, section.icon(), section.label());
ListItem::new(formatted).style(style)
})
.collect();
let title = format!(" {} ", self.topology.project_name);
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();
let selected_index = sections.iter().position(|s| *s == self.current_section).unwrap_or(0);
state.select(Some(selected_index));
f.render_stateful_widget(list, area, &mut state);
}
fn draw_details(&self, f: &mut Frame, area: ratatui::layout::Rect) {
match self.current_section {
Section::Services => self.draw_services_details(f, area),
Section::Nodes => self.draw_nodes_details(f, area),
Section::Topics => self.draw_topics_details(f, area),
}
}
fn draw_services_details(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let mut text = vec![
Line::from(vec![Span::styled(
"🌐 SERVICES",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if self.topology.services.is_empty() {
text.push(Line::from(vec![Span::styled(
"No services configured.",
Style::default().fg(Color::DarkGray),
)]));
text.push(Line::from(""));
text.push(Line::from("Services can include:"));
text.push(Line::from(" • HTTP API servers"));
text.push(Line::from(" • Database connections"));
text.push(Line::from(" • External integrations"));
} else {
for service in &self.topology.services {
text.push(Line::from(vec![Span::styled(
&service.name,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]));
text.push(Line::from(vec![
Span::raw(" Host: "),
Span::styled(&service.host, Style::default().fg(Color::Green)),
]));
text.push(Line::from(vec![
Span::raw(" Port: "),
Span::styled(service.port.to_string(), Style::default().fg(Color::Green)),
]));
text.push(Line::from(vec![
Span::raw(" URL: "),
Span::styled(
format!("http://{}:{}", service.host, service.port),
Style::default().fg(Color::Yellow),
),
]));
text.push(Line::from(""));
}
}
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Services ({}) ", self.topology.services.len()))
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_nodes_details(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let mut text = vec![
Line::from(vec![Span::styled(
"🔌 NODES & TOPICS",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if self.topology.nodes.is_empty() {
text.push(Line::from(vec![Span::styled(
"No nodes found.",
Style::default().fg(Color::DarkGray),
)]));
} else {
for node in &self.topology.nodes {
text.push(Line::from(vec![
Span::raw("📦 "),
Span::styled(
&node.name,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
]));
text.push(Line::from(vec![
Span::raw(" Package: "),
Span::styled(&node.package, Style::default().fg(Color::Yellow)),
]));
if let Some(desc) = &node.description {
text.push(Line::from(vec![
Span::raw(" "),
Span::styled(desc, Style::default().fg(Color::DarkGray)),
]));
}
if !node.publishes.is_empty() {
text.push(Line::from(vec![Span::styled(
" ├─ Publishes:",
Style::default().fg(Color::DarkGray),
)]));
for topic in &node.publishes {
let msg_type = topic
.message_type
.as_ref()
.map(|t| format!(" ({})", t))
.unwrap_or_default();
text.push(Line::from(vec![
Span::raw(" │ ├─ "),
Span::styled(&topic.path, Style::default().fg(Color::Green)),
Span::styled(msg_type, Style::default().fg(Color::Yellow)),
]));
}
} else {
text.push(Line::from(vec![Span::styled(
" ├─ Publishes: (none)",
Style::default().fg(Color::DarkGray),
)]));
}
if !node.subscribes.is_empty() {
text.push(Line::from(vec![Span::styled(
" └─ Subscribes:",
Style::default().fg(Color::DarkGray),
)]));
for topic in &node.subscribes {
let msg_type = topic
.message_type
.as_ref()
.map(|t| format!(" ({})", t))
.unwrap_or_default();
text.push(Line::from(vec![
Span::raw(" ├─ "),
Span::styled(&topic.path, Style::default().fg(Color::Blue)),
Span::styled(msg_type, Style::default().fg(Color::Yellow)),
]));
}
} else {
text.push(Line::from(vec![Span::styled(
" └─ Subscribes: (none)",
Style::default().fg(Color::DarkGray),
)]));
}
text.push(Line::from(""));
}
}
let text: Vec<Line> = text.into_iter().skip(self.detail_scroll_offset).collect();
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Nodes ({}) ", self.topology.nodes.len()))
.border_style(Style::default().fg(Color::Yellow)),
)
.wrap(Wrap { trim: true });
f.render_widget(paragraph, area);
}
fn draw_topics_details(&self, f: &mut Frame, area: ratatui::layout::Rect) {
let mut text = vec![
Line::from(vec![Span::styled(
"📨 TOPICS",
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
)]),
Line::from(""),
];
if self.topology.topics.is_empty() {
text.push(Line::from(vec![Span::styled(
"No topics found.",
Style::default().fg(Color::DarkGray),
)]));
} else {
for topic in &self.topology.topics {
let msg_type = topic
.message_type
.as_ref()
.map(|t| format!(" ({})", t))
.unwrap_or_default();
text.push(Line::from(vec![
Span::raw("📨 "),
Span::styled(
&topic.path,
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
),
Span::styled(msg_type, Style::default().fg(Color::Yellow)),
]));
if !topic.publishers.is_empty() {
text.push(Line::from(vec![Span::styled(
" ├─ Publishers:",
Style::default().fg(Color::DarkGray),
)]));
for node in &topic.publishers {
text.push(Line::from(vec![
Span::raw(" │ ├─ "),
Span::styled(node, Style::default().fg(Color::Green)),
]));
}
} else {
text.push(Line::from(vec![Span::styled(
" ├─ Publishers: (none)",
Style::default().fg(Color::DarkGray),
)]));
}
if !topic.subscribers.is_empty() {
text.push(Line::from(vec![Span::styled(
" └─ Subscribers:",
Style::default().fg(Color::DarkGray),
)]));
for node in &topic.subscribers {
text.push(Line::from(vec![
Span::raw(" ├─ "),
Span::styled(node, Style::default().fg(Color::Blue)),
]));
}
} else {
text.push(Line::from(vec![Span::styled(
" └─ Subscribers: (none)",
Style::default().fg(Color::DarkGray),
)]));
}
text.push(Line::from(""));
}
}
let text: Vec<Line> = text.into_iter().skip(self.detail_scroll_offset).collect();
let paragraph = Paragraph::new(text)
.block(
Block::default()
.borders(Borders::ALL)
.title(format!(" Topics ({}) ", self.topology.topics.len()))
.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("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 => {
let sections = Section::all();
let current_index = sections.iter().position(|s| *s == self.current_section).unwrap_or(0);
if current_index > 0 {
self.current_section = sections[current_index - 1];
self.detail_scroll_offset = 0; }
}
KeyCode::Down => {
let sections = Section::all();
let current_index = sections.iter().position(|s| *s == self.current_section).unwrap_or(0);
if current_index < sections.len() - 1 {
self.current_section = sections[current_index + 1];
self.detail_scroll_offset = 0; }
}
KeyCode::PageUp | KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(10);
}
KeyCode::PageDown | KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
self.detail_scroll_offset = self.detail_scroll_offset.saturating_add(10);
}
KeyCode::Char('k') => {
self.detail_scroll_offset = self.detail_scroll_offset.saturating_sub(1);
}
KeyCode::Char('j') => {
self.detail_scroll_offset = self.detail_scroll_offset.saturating_add(1);
}
_ => {}
}
}
}