Skip to main content

ghostscope_ui/components/loading/
ui.rs

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/// Enhanced Loading UI component with detailed progress tracking
19#[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    /// Update spinner animation based on elapsed time
38    pub fn update(&mut self) {
39        let elapsed = self.start_time.elapsed();
40        // Update spinner every 100ms
41        let frames = (elapsed.as_millis() / 100) as usize;
42        self.current_spinner_idx = frames % self.spinner_chars.len();
43    }
44
45    /// Get current spinner character
46    fn current_spinner(&self) -> char {
47        self.spinner_chars[self.current_spinner_idx]
48    }
49
50    /// Get formatted elapsed time
51    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    /// Render enhanced loading screen with DWARF loading progress
64    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        // Clear the entire screen
73        f.render_widget(Clear, f.area());
74
75        // Create main layout - more space for content
76        let main_chunks = Layout::default()
77            .direction(Direction::Vertical)
78            .constraints([
79                Constraint::Fill(1),
80                Constraint::Length(19), // Height for loading box - increased for wrap support
81                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), // Width for loading box - increased
90                Constraint::Fill(1),
91            ])
92            .split(main_chunks[1]);
93
94        let loading_area = horizontal_chunks[1];
95
96        // Main loading container with enhanced styling
97        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        // Inner content area
107        let inner_area = loading_area.inner(ratatui::layout::Margin {
108            vertical: 1,
109            horizontal: 2,
110        });
111
112        // Create content layout
113        let content_chunks = Layout::default()
114            .direction(Direction::Vertical)
115            .constraints([
116                Constraint::Length(2), // Header line (2 lines for potential wrap)
117                Constraint::Length(1), // Copyright line
118                Constraint::Length(1), // License line
119                Constraint::Length(1), // Empty line
120                Constraint::Length(1), // Loading status line
121                Constraint::Length(1), // Empty line
122                Constraint::Length(1), // Progress bar
123                Constraint::Length(1), // Empty line
124                Constraint::Length(4), // Recently loaded modules (4 lines)
125                Constraint::Length(1), // Current loading status
126                Constraint::Length(1), // Stats line
127            ])
128            .split(inner_area);
129
130        // Header - with wrap support for narrow terminals
131        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        // Copyright
149        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        // License
157        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        // Loading status with PID
165        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        // Progress bar - only show if we have modules
184        if !loading_ui.progress.modules.is_empty() {
185            ProgressRenderer::render_progress_bar(f, content_chunks[6], &loading_ui.progress);
186        }
187
188        // Recently loaded modules
189        ProgressRenderer::render_recent_modules(f, content_chunks[8], &loading_ui.progress, 4);
190
191        // Current loading status
192        ProgressRenderer::render_current_status(f, content_chunks[9], &loading_ui.progress);
193
194        // Stats line
195        ProgressRenderer::render_stats(f, content_chunks[10], &loading_ui.progress);
196    }
197
198    /// Generate styled welcome message for command panel
199    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        // Module loading stats in white
229        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        // DWARF statistics in yellow
246        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        // Empty line
265        lines.push(Line::from(""));
266
267        // Bug reporting info in gray
268        lines.push(Line::from(Span::styled(
269            "For bug reporting instructions, please see:",
270            Style::default().fg(Color::Gray),
271        )));
272
273        // GitHub URL in white
274        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    /// Generate completion summary for command panel (backward compatibility)
283    pub fn generate_completion_summary(&self, total_time: f64) -> Vec<String> {
284        // Convert styled lines back to strings for backward compatibility
285        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    /// Render the simple loading screen (fallback for non-DWARF loading)
297    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        // Clear the entire screen
306        f.render_widget(Clear, f.area());
307
308        // Create centered layout
309        let vertical_chunks = Layout::default()
310            .direction(Direction::Vertical)
311            .constraints([
312                Constraint::Fill(1),
313                Constraint::Length(8), // Height for loading box
314                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), // Width for loading box
323                Constraint::Fill(1),
324            ])
325            .split(vertical_chunks[1]);
326
327        let loading_area = horizontal_chunks[1];
328
329        // Main loading container
330        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        // Inner content area
340        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), // Spinner line
349                Constraint::Length(1), // Message line
350                Constraint::Length(1), // Empty line
351                Constraint::Length(1), // Progress bar (if present)
352                Constraint::Length(1), // Time line
353            ])
354            .split(inner_area);
355
356        // Spinner and status line
357        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        // Message line
376        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        // Progress bar (if progress is provided)
384        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        // Elapsed time
399        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    /// Render a smaller loading indicator in a specific area
408    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}