1use ratatui::{
2 layout::{Alignment, Constraint, Direction, Layout, Rect},
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, BorderType, Borders, Clear, Paragraph},
6 Frame,
7};
8use std::time::Instant;
9
10use super::{
11 debug_source_style, push_debug_source_count_spans, LoadingProgress, LoadingState,
12 ModuleLoadStatus, ModuleState, ProgressRenderer,
13};
14
15const MAX_WELCOME_DEBUG_SOURCE_DETAILS: usize = 8;
16const MAX_WELCOME_MISSING_EXAMPLES: usize = 3;
17
18#[derive(Clone, Debug)]
20pub struct LoadingUI {
21 start_time: Instant,
22 spinner_chars: Vec<char>,
23 current_spinner_idx: usize,
24 pub progress: LoadingProgress,
25}
26
27impl LoadingUI {
28 pub fn new() -> Self {
29 Self {
30 start_time: Instant::now(),
31 spinner_chars: vec!['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'],
32 current_spinner_idx: 0,
33 progress: LoadingProgress::new(),
34 }
35 }
36
37 pub fn update(&mut self) {
39 let elapsed = self.start_time.elapsed();
40 let frames = (elapsed.as_millis() / 100) as usize;
42 self.current_spinner_idx = frames % self.spinner_chars.len();
43 }
44
45 fn current_spinner(&self) -> char {
47 self.spinner_chars[self.current_spinner_idx]
48 }
49
50 fn elapsed_time(&self) -> String {
52 let elapsed = self.start_time.elapsed();
53 let total_seconds = elapsed.as_secs_f64();
54 if total_seconds < 60.0 {
55 format!("{total_seconds:.1}s")
56 } else {
57 let minutes = (total_seconds / 60.0).floor() as u64;
58 let remaining_seconds = total_seconds - (minutes as f64) * 60.0;
59 format!("{minutes}m{remaining_seconds:.1}s")
60 }
61 }
62
63 pub fn render_dwarf_loading(
65 f: &mut Frame,
66 loading_ui: &mut LoadingUI,
67 loading_state: &LoadingState,
68 pid: Option<u32>,
69 ) {
70 loading_ui.update();
71
72 f.render_widget(Clear, f.area());
74
75 let main_chunks = Layout::default()
77 .direction(Direction::Vertical)
78 .constraints([
79 Constraint::Fill(1),
80 Constraint::Length(19), Constraint::Fill(1),
82 ])
83 .split(f.area());
84
85 let horizontal_chunks = Layout::default()
86 .direction(Direction::Horizontal)
87 .constraints([
88 Constraint::Fill(1),
89 Constraint::Length(78), Constraint::Fill(1),
91 ])
92 .split(main_chunks[1]);
93
94 let loading_area = horizontal_chunks[1];
95
96 let loading_block = Block::default()
98 .title(" Ghostscope Tracer ")
99 .title_alignment(Alignment::Center)
100 .borders(Borders::ALL)
101 .border_type(BorderType::Rounded)
102 .border_style(Style::default().fg(Color::Cyan));
103
104 f.render_widget(loading_block, loading_area);
105
106 let inner_area = loading_area.inner(ratatui::layout::Margin {
108 vertical: 1,
109 horizontal: 2,
110 });
111
112 let content_chunks = Layout::default()
114 .direction(Direction::Vertical)
115 .constraints([
116 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), ])
128 .split(inner_area);
129
130 use ratatui::text::Text;
132 use ratatui::widgets::Wrap;
133
134 let header_text = Text::from(vec![Line::from(vec![
135 Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
136 Span::styled(
137 format!("Ghostscope v{} - A DWARF-aware eBPF tracer with cgdb-like TUI - explore live processes at runtime", env!("CARGO_PKG_VERSION")),
138 Style::default()
139 .fg(Color::White)
140 .add_modifier(Modifier::BOLD),
141 ),
142 ])]);
143 let header_paragraph = Paragraph::new(header_text)
144 .alignment(Alignment::Center)
145 .wrap(Wrap { trim: true });
146 f.render_widget(header_paragraph, content_chunks[0]);
147
148 let copyright_line = Line::from(Span::styled(
150 "Copyright (C) 2025 Ghostscope Project",
151 Style::default().fg(Color::Gray),
152 ));
153 let copyright_paragraph = Paragraph::new(copyright_line).alignment(Alignment::Center);
154 f.render_widget(copyright_paragraph, content_chunks[1]);
155
156 let license_line = Line::from(Span::styled(
158 "Licensed under GPL License",
159 Style::default().fg(Color::Gray),
160 ));
161 let license_paragraph = Paragraph::new(license_line).alignment(Alignment::Center);
162 f.render_widget(license_paragraph, content_chunks[2]);
163
164 let status_message = if let Some(pid) = pid {
166 format!("Loading debug information for PID {pid}...")
167 } else {
168 loading_state.message().to_string()
169 };
170
171 let status_line = Line::from(vec![
172 Span::styled(
173 format!("{} ", loading_ui.current_spinner()),
174 Style::default()
175 .fg(Color::Yellow)
176 .add_modifier(Modifier::BOLD),
177 ),
178 Span::styled(status_message, Style::default().fg(Color::White)),
179 ]);
180 let status_paragraph = Paragraph::new(status_line).alignment(Alignment::Center);
181 f.render_widget(status_paragraph, content_chunks[4]);
182
183 if !loading_ui.progress.modules.is_empty() {
185 ProgressRenderer::render_progress_bar(f, content_chunks[6], &loading_ui.progress);
186 }
187
188 ProgressRenderer::render_recent_modules(f, content_chunks[8], &loading_ui.progress, 4);
190
191 ProgressRenderer::render_current_status(f, content_chunks[9], &loading_ui.progress);
193
194 ProgressRenderer::render_stats(f, content_chunks[10], &loading_ui.progress);
196 }
197
198 pub fn create_welcome_message(&self, total_time: f64) -> Vec<ratatui::text::Line<'static>> {
200 use ratatui::style::{Color, Modifier, Style};
201 use ratatui::text::{Line, Span};
202
203 let total_stats = self.progress.total_stats();
204 let total_modules = self.progress.total_modules();
205 let failed_count = self.progress.failed_count;
206 let successful_modules = total_modules - failed_count;
207
208 let mut lines = vec![
209 Line::from(Span::styled(
210 format!("🔍 Ghostscope v{}", env!("CARGO_PKG_VERSION")),
211 Style::default()
212 .fg(Color::Cyan)
213 .add_modifier(Modifier::BOLD),
214 )),
215 Line::from(Span::styled(
216 "Licensed under GPL",
217 Style::default().fg(Color::Gray),
218 )),
219 Line::from(""),
220 Line::from(Span::styled(
221 "✅ Debug Information Loaded:",
222 Style::default()
223 .fg(Color::Green)
224 .add_modifier(Modifier::BOLD),
225 )),
226 ];
227
228 if failed_count > 0 {
230 lines.push(Line::from(Span::styled(
231 format!(
232 "• {successful_modules} modules loaded successfully ({failed_count} failed) in {total_time:.1} seconds"
233 ),
234 Style::default().fg(Color::White),
235 )));
236 } else {
237 lines.push(Line::from(Span::styled(
238 format!(
239 "• {successful_modules} modules loaded successfully in {total_time:.1} seconds"
240 ),
241 Style::default().fg(Color::White),
242 )));
243 }
244
245 let functions = total_stats.functions;
247 let variables = total_stats.variables;
248 let types = total_stats.types;
249 lines.push(Line::from(Span::styled(
250 format!("• {functions} functions, {variables} variables, {types} types indexed"),
251 Style::default().fg(Color::Yellow),
252 )));
253
254 if self.progress.debug_sources.has_counts() {
255 let mut source_spans = vec![Span::styled(
256 "• Debug sources: ",
257 Style::default().fg(Color::White),
258 )];
259 push_debug_source_count_spans(&mut source_spans, &self.progress.debug_sources);
260 lines.push(Line::from(source_spans));
261 append_debug_source_details(&mut lines, &self.progress);
262 }
263
264 lines.push(Line::from(""));
266
267 lines.push(Line::from(Span::styled(
269 "For bug reporting instructions, please see:",
270 Style::default().fg(Color::Gray),
271 )));
272
273 lines.push(Line::from(Span::styled(
275 "https://github.com/swananan/ghostscope/issues",
276 Style::default().fg(Color::White),
277 )));
278
279 lines
280 }
281
282 pub fn generate_completion_summary(&self, total_time: f64) -> Vec<String> {
284 self.create_welcome_message(total_time)
286 .into_iter()
287 .map(|line| {
288 line.spans
289 .into_iter()
290 .map(|span| span.content.to_string())
291 .collect::<String>()
292 })
293 .collect()
294 }
295
296 pub fn render_simple(
298 f: &mut Frame,
299 loading_ui: &mut LoadingUI,
300 message: &str,
301 progress: Option<f64>,
302 ) {
303 loading_ui.update();
304
305 f.render_widget(Clear, f.area());
307
308 let vertical_chunks = Layout::default()
310 .direction(Direction::Vertical)
311 .constraints([
312 Constraint::Fill(1),
313 Constraint::Length(8), Constraint::Fill(1),
315 ])
316 .split(f.area());
317
318 let horizontal_chunks = Layout::default()
319 .direction(Direction::Horizontal)
320 .constraints([
321 Constraint::Fill(1),
322 Constraint::Length(60), Constraint::Fill(1),
324 ])
325 .split(vertical_chunks[1]);
326
327 let loading_area = horizontal_chunks[1];
328
329 let loading_block = Block::default()
331 .title(" Ghostscope ")
332 .title_alignment(Alignment::Center)
333 .borders(Borders::ALL)
334 .border_type(BorderType::Rounded)
335 .border_style(Style::default().fg(Color::Cyan));
336
337 f.render_widget(loading_block, loading_area);
338
339 let inner_area = loading_area.inner(ratatui::layout::Margin {
341 vertical: 1,
342 horizontal: 2,
343 });
344
345 let content_chunks = Layout::default()
346 .direction(Direction::Vertical)
347 .constraints([
348 Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), ])
354 .split(inner_area);
355
356 let spinner_line = Line::from(vec![
358 Span::styled(
359 format!("{} ", loading_ui.current_spinner()),
360 Style::default()
361 .fg(Color::Yellow)
362 .add_modifier(Modifier::BOLD),
363 ),
364 Span::styled(
365 "Loading Ghostscope...",
366 Style::default()
367 .fg(Color::White)
368 .add_modifier(Modifier::BOLD),
369 ),
370 ]);
371
372 let spinner_paragraph = Paragraph::new(spinner_line).alignment(Alignment::Center);
373 f.render_widget(spinner_paragraph, content_chunks[0]);
374
375 let message_paragraph = Paragraph::new(Line::from(Span::styled(
377 message,
378 Style::default().fg(Color::Gray),
379 )))
380 .alignment(Alignment::Center);
381 f.render_widget(message_paragraph, content_chunks[1]);
382
383 if let Some(progress_value) = progress {
385 use ratatui::widgets::{Gauge, Padding};
386 let progress_bar = Gauge::default()
387 .block(
388 Block::default()
389 .borders(Borders::NONE)
390 .padding(Padding::horizontal(1)),
391 )
392 .gauge_style(Style::default().fg(Color::Cyan))
393 .ratio(progress_value.clamp(0.0, 1.0))
394 .label(format!("{:.0}%", progress_value * 100.0));
395 f.render_widget(progress_bar, content_chunks[3]);
396 }
397
398 let time_line = Line::from(Span::styled(
400 format!("Elapsed: {}", loading_ui.elapsed_time()),
401 Style::default().fg(Color::DarkGray),
402 ));
403 let time_paragraph = Paragraph::new(time_line).alignment(Alignment::Center);
404 f.render_widget(time_paragraph, content_chunks[4]);
405 }
406
407 pub fn render_inline(f: &mut Frame, area: Rect, loading_ui: &mut LoadingUI, message: &str) {
409 loading_ui.update();
410
411 let spinner_text = format!("{} {}", loading_ui.current_spinner(), message);
412 let paragraph = Paragraph::new(Line::from(Span::styled(
413 spinner_text,
414 Style::default().fg(Color::Yellow),
415 )));
416
417 f.render_widget(paragraph, area);
418 }
419}
420
421fn append_debug_source_details(lines: &mut Vec<Line<'static>>, progress: &LoadingProgress) {
422 let debug_source_modules: Vec<&ModuleLoadStatus> = progress
423 .modules
424 .iter()
425 .filter(|module| matches!(module.state, ModuleState::Completed))
426 .filter(|module| {
427 module.stats.as_ref().is_some_and(|stats| {
428 stats.debug_source != "missing" && stats.debug_source_path.is_some()
429 })
430 })
431 .collect();
432
433 if !debug_source_modules.is_empty() {
434 lines.push(Line::from(Span::styled(
435 "• Debug source files:",
436 Style::default().fg(Color::White),
437 )));
438
439 for module in debug_source_modules
440 .iter()
441 .take(MAX_WELCOME_DEBUG_SOURCE_DETAILS)
442 {
443 if let Some(stats) = &module.stats {
444 if let Some(path) = stats.debug_source_path.as_deref() {
445 lines.push(Line::from(vec![
446 Span::raw(" "),
447 Span::styled(
448 format!("{:<10}", stats.debug_source),
449 debug_source_style(&stats.debug_source),
450 ),
451 Span::styled(" ", Style::default().fg(Color::DarkGray)),
452 Span::styled(
453 shorten_middle(&module_file_name(&module.path), 34),
454 Style::default().fg(Color::White),
455 ),
456 Span::styled(" ", Style::default().fg(Color::DarkGray)),
457 Span::styled(shorten_path(path, 80), Style::default().fg(Color::Gray)),
458 ]));
459 }
460 }
461 }
462
463 if debug_source_modules.len() > MAX_WELCOME_DEBUG_SOURCE_DETAILS {
464 lines.push(Line::from(Span::styled(
465 format!(
466 " ... {} more module(s) omitted",
467 debug_source_modules.len() - MAX_WELCOME_DEBUG_SOURCE_DETAILS
468 ),
469 Style::default().fg(Color::DarkGray),
470 )));
471 }
472 }
473
474 if progress.debug_sources.missing > 0 {
475 lines.push(Line::from(vec![
476 Span::styled("• Missing DWARF: ", Style::default().fg(Color::Yellow)),
477 Span::styled(
478 missing_module_hint(progress),
479 Style::default().fg(Color::Yellow),
480 ),
481 ]));
482 }
483}
484
485fn missing_module_hint(progress: &LoadingProgress) -> String {
486 let missing_modules: Vec<&ModuleLoadStatus> = progress
487 .modules
488 .iter()
489 .filter(|module| matches!(module.state, ModuleState::Completed))
490 .filter(|module| {
491 module
492 .stats
493 .as_ref()
494 .is_some_and(|stats| stats.debug_source == "missing")
495 })
496 .collect();
497
498 let count = missing_modules.len();
499 if count == 0 {
500 return "0 modules".to_string();
501 }
502
503 let examples: Vec<String> = missing_modules
504 .iter()
505 .take(MAX_WELCOME_MISSING_EXAMPLES)
506 .map(|module| module_file_name(&module.path))
507 .collect();
508
509 let mut message = format!("{count} module{}", if count == 1 { "" } else { "s" });
510 if !examples.is_empty() {
511 message.push_str(&format!(" ({})", examples.join(", ")));
512 if count > examples.len() {
513 message.push_str(&format!(" +{} more", count - examples.len()));
514 }
515 }
516 message
517}
518
519fn module_file_name(path: &str) -> String {
520 std::path::Path::new(path)
521 .file_name()
522 .and_then(|name| name.to_str())
523 .unwrap_or(path)
524 .to_string()
525}
526
527fn shorten_path(path: &str, max_width: usize) -> String {
528 let width = path.chars().count();
529 if width <= max_width {
530 return path.to_string();
531 }
532
533 if max_width <= 3 {
534 return ".".repeat(max_width);
535 }
536
537 let suffix: String = path.chars().skip(width - (max_width - 3)).collect();
538 format!("...{suffix}")
539}
540
541fn shorten_middle(value: &str, max_width: usize) -> String {
542 let width = value.chars().count();
543 if width <= max_width {
544 return value.to_string();
545 }
546
547 if max_width <= 3 {
548 return ".".repeat(max_width);
549 }
550
551 let prefix_width = (max_width - 3) / 2;
552 let suffix_width = max_width - 3 - prefix_width;
553 let prefix: String = value.chars().take(prefix_width).collect();
554 let suffix: String = value.chars().skip(width - suffix_width).collect();
555 format!("{prefix}...{suffix}")
556}
557
558impl Default for LoadingUI {
559 fn default() -> Self {
560 Self::new()
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::*;
567 use crate::components::loading::ModuleStats;
568
569 #[test]
570 fn welcome_message_includes_debug_sources_and_paths() {
571 let mut loading_ui = LoadingUI::new();
572
573 loading_ui
574 .progress
575 .add_module("/usr/local/openresty/luajit/lib/libluajit-5.1.so.2.1.ROLLING".to_string());
576 loading_ui.progress.complete_module(
577 "/usr/local/openresty/luajit/lib/libluajit-5.1.so.2.1.ROLLING",
578 ModuleStats {
579 functions: 42,
580 variables: 7,
581 types: 11,
582 debug_source: "embedded".to_string(),
583 debug_source_path: Some(
584 "/usr/local/openresty/luajit/lib/libluajit-5.1.so.2.1.ROLLING".to_string(),
585 ),
586 },
587 );
588
589 loading_ui
590 .progress
591 .add_module("/usr/lib/x86_64-linux-gnu/libcrypt.so.1.1.0".to_string());
592 loading_ui.progress.complete_module(
593 "/usr/lib/x86_64-linux-gnu/libcrypt.so.1.1.0",
594 ModuleStats {
595 functions: 0,
596 variables: 0,
597 types: 0,
598 debug_source: "missing".to_string(),
599 debug_source_path: None,
600 },
601 );
602
603 let text = plain_text(&loading_ui.create_welcome_message(1.25));
604
605 assert!(text.contains("Debug sources: embedded:1"));
606 assert!(text.contains("missing:1"));
607 assert!(text.contains("Debug source files:"));
608 assert!(text.contains("embedded"));
609 assert!(text.contains("libluajit-5.1.so.2.1.ROLLING /usr/local/openresty/luajit/lib/"));
610 assert!(text.contains("Missing DWARF: 1 module (libcrypt.so.1.1.0)"));
611 }
612
613 fn plain_text(lines: &[Line<'static>]) -> String {
614 lines
615 .iter()
616 .map(|line| {
617 line.spans
618 .iter()
619 .map(|span| span.content.as_ref())
620 .collect::<String>()
621 })
622 .collect::<Vec<_>>()
623 .join("\n")
624 }
625}