use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, BorderType, Borders, Clear, Paragraph},
Frame,
};
use std::time::Instant;
use super::{
debug_source_style, push_debug_source_count_spans, LoadingProgress, LoadingState,
ModuleLoadStatus, ModuleState, ProgressRenderer,
};
const MAX_WELCOME_DEBUG_SOURCE_DETAILS: usize = 8;
const MAX_WELCOME_MISSING_EXAMPLES: usize = 3;
#[derive(Clone, Debug)]
pub struct LoadingUI {
start_time: Instant,
spinner_chars: Vec<char>,
current_spinner_idx: usize,
pub progress: LoadingProgress,
}
impl LoadingUI {
pub fn new() -> Self {
Self {
start_time: Instant::now(),
spinner_chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
current_spinner_idx: 0,
progress: LoadingProgress::new(),
}
}
pub fn update(&mut self) {
let elapsed = self.start_time.elapsed();
let frames = (elapsed.as_millis() / 100) as usize;
self.current_spinner_idx = frames % self.spinner_chars.len();
}
fn current_spinner(&self) -> char {
self.spinner_chars[self.current_spinner_idx]
}
fn elapsed_time(&self) -> String {
let elapsed = self.start_time.elapsed();
let total_seconds = elapsed.as_secs_f64();
if total_seconds < 60.0 {
format!("{total_seconds:.1}s")
} else {
let minutes = (total_seconds / 60.0).floor() as u64;
let remaining_seconds = total_seconds - (minutes as f64) * 60.0;
format!("{minutes}m{remaining_seconds:.1}s")
}
}
pub fn render_dwarf_loading(
f: &mut Frame,
loading_ui: &mut LoadingUI,
loading_state: &LoadingState,
pid: Option<u32>,
) {
loading_ui.update();
f.render_widget(Clear, f.area());
let main_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(19), Constraint::Fill(1),
])
.split(f.area());
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(78), Constraint::Fill(1),
])
.split(main_chunks[1]);
let loading_area = horizontal_chunks[1];
let loading_block = Block::default()
.title(" Ghostscope Tracer ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(loading_block, loading_area);
let inner_area = loading_area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 2,
});
let content_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(4), Constraint::Length(1), Constraint::Length(1), ])
.split(inner_area);
use ratatui::text::Text;
use ratatui::widgets::Wrap;
let header_text = Text::from(vec![Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(
format!("Ghostscope v{} - A DWARF-aware eBPF tracer with cgdb-like TUI - explore live processes at runtime", env!("CARGO_PKG_VERSION")),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
])]);
let header_paragraph = Paragraph::new(header_text)
.alignment(Alignment::Center)
.wrap(Wrap { trim: true });
f.render_widget(header_paragraph, content_chunks[0]);
let copyright_line = Line::from(Span::styled(
"Copyright (C) 2025 Ghostscope Project",
Style::default().fg(Color::Gray),
));
let copyright_paragraph = Paragraph::new(copyright_line).alignment(Alignment::Center);
f.render_widget(copyright_paragraph, content_chunks[1]);
let license_line = Line::from(Span::styled(
"Licensed under GPL License",
Style::default().fg(Color::Gray),
));
let license_paragraph = Paragraph::new(license_line).alignment(Alignment::Center);
f.render_widget(license_paragraph, content_chunks[2]);
let status_message = if let Some(pid) = pid {
format!("Loading debug information for PID {pid}...")
} else {
loading_state.message().to_string()
};
let status_line = Line::from(vec![
Span::styled(
format!("{} ", loading_ui.current_spinner()),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(status_message, Style::default().fg(Color::White)),
]);
let status_paragraph = Paragraph::new(status_line).alignment(Alignment::Center);
f.render_widget(status_paragraph, content_chunks[4]);
if !loading_ui.progress.modules.is_empty() {
ProgressRenderer::render_progress_bar(f, content_chunks[6], &loading_ui.progress);
}
ProgressRenderer::render_recent_modules(f, content_chunks[8], &loading_ui.progress, 4);
ProgressRenderer::render_current_status(f, content_chunks[9], &loading_ui.progress);
ProgressRenderer::render_stats(f, content_chunks[10], &loading_ui.progress);
}
pub fn create_welcome_message(&self, total_time: f64) -> Vec<ratatui::text::Line<'static>> {
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
let total_stats = self.progress.total_stats();
let total_modules = self.progress.total_modules();
let failed_count = self.progress.failed_count;
let successful_modules = total_modules - failed_count;
let mut lines = vec![
Line::from(Span::styled(
format!("🔍 Ghostscope v{}", env!("CARGO_PKG_VERSION")),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)),
Line::from(Span::styled(
"Licensed under GPL",
Style::default().fg(Color::Gray),
)),
Line::from(""),
Line::from(Span::styled(
"✅ Debug Information Loaded:",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
)),
];
if failed_count > 0 {
lines.push(Line::from(Span::styled(
format!(
"• {successful_modules} modules loaded successfully ({failed_count} failed) in {total_time:.1} seconds"
),
Style::default().fg(Color::White),
)));
} else {
lines.push(Line::from(Span::styled(
format!(
"• {successful_modules} modules loaded successfully in {total_time:.1} seconds"
),
Style::default().fg(Color::White),
)));
}
let functions = total_stats.functions;
let variables = total_stats.variables;
let types = total_stats.types;
lines.push(Line::from(Span::styled(
format!("• {functions} functions, {variables} variables, {types} types indexed"),
Style::default().fg(Color::Yellow),
)));
if self.progress.debug_sources.has_counts() {
let mut source_spans = vec![Span::styled(
"• Debug sources: ",
Style::default().fg(Color::White),
)];
push_debug_source_count_spans(&mut source_spans, &self.progress.debug_sources);
lines.push(Line::from(source_spans));
append_debug_source_details(&mut lines, &self.progress);
}
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"For bug reporting instructions, please see:",
Style::default().fg(Color::Gray),
)));
lines.push(Line::from(Span::styled(
"https://github.com/swananan/ghostscope/issues",
Style::default().fg(Color::White),
)));
lines
}
pub fn generate_completion_summary(&self, total_time: f64) -> Vec<String> {
self.create_welcome_message(total_time)
.into_iter()
.map(|line| {
line.spans
.into_iter()
.map(|span| span.content.to_string())
.collect::<String>()
})
.collect()
}
pub fn render_simple(
f: &mut Frame,
loading_ui: &mut LoadingUI,
message: &str,
progress: Option<f64>,
) {
loading_ui.update();
f.render_widget(Clear, f.area());
let vertical_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Fill(1),
Constraint::Length(8), Constraint::Fill(1),
])
.split(f.area());
let horizontal_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(60), Constraint::Fill(1),
])
.split(vertical_chunks[1]);
let loading_area = horizontal_chunks[1];
let loading_block = Block::default()
.title(" Ghostscope ")
.title_alignment(Alignment::Center)
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(Color::Cyan));
f.render_widget(loading_block, loading_area);
let inner_area = loading_area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 2,
});
let content_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
.split(inner_area);
let spinner_line = Line::from(vec![
Span::styled(
format!("{} ", loading_ui.current_spinner()),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"Loading Ghostscope...",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
),
]);
let spinner_paragraph = Paragraph::new(spinner_line).alignment(Alignment::Center);
f.render_widget(spinner_paragraph, content_chunks[0]);
let message_paragraph = Paragraph::new(Line::from(Span::styled(
message,
Style::default().fg(Color::Gray),
)))
.alignment(Alignment::Center);
f.render_widget(message_paragraph, content_chunks[1]);
if let Some(progress_value) = progress {
use ratatui::widgets::{Gauge, Padding};
let progress_bar = Gauge::default()
.block(
Block::default()
.borders(Borders::NONE)
.padding(Padding::horizontal(1)),
)
.gauge_style(Style::default().fg(Color::Cyan))
.ratio(progress_value.clamp(0.0, 1.0))
.label(format!("{:.0}%", progress_value * 100.0));
f.render_widget(progress_bar, content_chunks[3]);
}
let time_line = Line::from(Span::styled(
format!("Elapsed: {}", loading_ui.elapsed_time()),
Style::default().fg(Color::DarkGray),
));
let time_paragraph = Paragraph::new(time_line).alignment(Alignment::Center);
f.render_widget(time_paragraph, content_chunks[4]);
}
pub fn render_inline(f: &mut Frame, area: Rect, loading_ui: &mut LoadingUI, message: &str) {
loading_ui.update();
let spinner_text = format!("{} {}", loading_ui.current_spinner(), message);
let paragraph = Paragraph::new(Line::from(Span::styled(
spinner_text,
Style::default().fg(Color::Yellow),
)));
f.render_widget(paragraph, area);
}
}
fn append_debug_source_details(lines: &mut Vec<Line<'static>>, progress: &LoadingProgress) {
let debug_source_modules: Vec<&ModuleLoadStatus> = progress
.modules
.iter()
.filter(|module| matches!(module.state, ModuleState::Completed))
.filter(|module| {
module.stats.as_ref().is_some_and(|stats| {
stats.debug_source != "missing" && stats.debug_source_path.is_some()
})
})
.collect();
if !debug_source_modules.is_empty() {
lines.push(Line::from(Span::styled(
"• Debug source files:",
Style::default().fg(Color::White),
)));
for module in debug_source_modules
.iter()
.take(MAX_WELCOME_DEBUG_SOURCE_DETAILS)
{
if let Some(stats) = &module.stats {
if let Some(path) = stats.debug_source_path.as_deref() {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(
format!("{:<10}", stats.debug_source),
debug_source_style(&stats.debug_source),
),
Span::styled(" ", Style::default().fg(Color::DarkGray)),
Span::styled(
shorten_middle(&module_file_name(&module.path), 34),
Style::default().fg(Color::White),
),
Span::styled(" ", Style::default().fg(Color::DarkGray)),
Span::styled(shorten_path(path, 80), Style::default().fg(Color::Gray)),
]));
}
}
}
if debug_source_modules.len() > MAX_WELCOME_DEBUG_SOURCE_DETAILS {
lines.push(Line::from(Span::styled(
format!(
" ... {} more module(s) omitted",
debug_source_modules.len() - MAX_WELCOME_DEBUG_SOURCE_DETAILS
),
Style::default().fg(Color::DarkGray),
)));
}
}
if progress.debug_sources.missing > 0 {
lines.push(Line::from(vec![
Span::styled("• Missing DWARF: ", Style::default().fg(Color::Yellow)),
Span::styled(
missing_module_hint(progress),
Style::default().fg(Color::Yellow),
),
]));
}
}
fn missing_module_hint(progress: &LoadingProgress) -> String {
let missing_modules: Vec<&ModuleLoadStatus> = progress
.modules
.iter()
.filter(|module| matches!(module.state, ModuleState::Completed))
.filter(|module| {
module
.stats
.as_ref()
.is_some_and(|stats| stats.debug_source == "missing")
})
.collect();
let count = missing_modules.len();
if count == 0 {
return "0 modules".to_string();
}
let examples: Vec<String> = missing_modules
.iter()
.take(MAX_WELCOME_MISSING_EXAMPLES)
.map(|module| module_file_name(&module.path))
.collect();
let mut message = format!("{count} module{}", if count == 1 { "" } else { "s" });
if !examples.is_empty() {
message.push_str(&format!(" ({})", examples.join(", ")));
if count > examples.len() {
message.push_str(&format!(" +{} more", count - examples.len()));
}
}
message
}
fn module_file_name(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(path)
.to_string()
}
fn shorten_path(path: &str, max_width: usize) -> String {
let width = path.chars().count();
if width <= max_width {
return path.to_string();
}
if max_width <= 3 {
return ".".repeat(max_width);
}
let suffix: String = path.chars().skip(width - (max_width - 3)).collect();
format!("...{suffix}")
}
fn shorten_middle(value: &str, max_width: usize) -> String {
let width = value.chars().count();
if width <= max_width {
return value.to_string();
}
if max_width <= 3 {
return ".".repeat(max_width);
}
let prefix_width = (max_width - 3) / 2;
let suffix_width = max_width - 3 - prefix_width;
let prefix: String = value.chars().take(prefix_width).collect();
let suffix: String = value.chars().skip(width - suffix_width).collect();
format!("{prefix}...{suffix}")
}
impl Default for LoadingUI {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::components::loading::ModuleStats;
#[test]
fn welcome_message_includes_debug_sources_and_paths() {
let mut loading_ui = LoadingUI::new();
loading_ui
.progress
.add_module("/usr/local/openresty/luajit/lib/libluajit-5.1.so.2.1.ROLLING".to_string());
loading_ui.progress.complete_module(
"/usr/local/openresty/luajit/lib/libluajit-5.1.so.2.1.ROLLING",
ModuleStats {
functions: 42,
variables: 7,
types: 11,
debug_source: "embedded".to_string(),
debug_source_path: Some(
"/usr/local/openresty/luajit/lib/libluajit-5.1.so.2.1.ROLLING".to_string(),
),
},
);
loading_ui
.progress
.add_module("/usr/lib/x86_64-linux-gnu/libcrypt.so.1.1.0".to_string());
loading_ui.progress.complete_module(
"/usr/lib/x86_64-linux-gnu/libcrypt.so.1.1.0",
ModuleStats {
functions: 0,
variables: 0,
types: 0,
debug_source: "missing".to_string(),
debug_source_path: None,
},
);
let text = plain_text(&loading_ui.create_welcome_message(1.25));
assert!(text.contains("Debug sources: embedded:1"));
assert!(text.contains("missing:1"));
assert!(text.contains("Debug source files:"));
assert!(text.contains("embedded"));
assert!(text.contains("libluajit-5.1.so.2.1.ROLLING /usr/local/openresty/luajit/lib/"));
assert!(text.contains("Missing DWARF: 1 module (libcrypt.so.1.1.0)"));
}
fn plain_text(lines: &[Line<'static>]) -> String {
lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
}