Skip to main content

ghostscope_ui/components/ebpf_panel/
renderer.rs

1use crate::events::{BacktraceDisplay, BacktraceDisplayFrame, TraceDisplayItem};
2use crate::model::panel_state::{DisplayMode, EbpfPanelState, EbpfViewMode};
3use crate::ui::themes::UIThemes;
4use ghostscope_protocol::trace_event::BacktraceStatus;
5use ratatui::{
6    layout::Rect,
7    style::{Color, Modifier, Style},
8    text::{Line, Span},
9    widgets::{Block, BorderType, Borders, Paragraph},
10    Frame,
11};
12
13/// Renders the eBPF output panel
14#[derive(Debug)]
15pub struct EbpfPanelRenderer;
16
17impl EbpfPanelRenderer {
18    pub fn new() -> Self {
19        Self
20    }
21
22    /// Render the eBPF panel
23    pub fn render(
24        &mut self,
25        state: &mut EbpfPanelState,
26        frame: &mut Frame,
27        area: Rect,
28        is_focused: bool,
29    ) {
30        // Outer panel block
31        let border_style = if is_focused {
32            UIThemes::panel_focused()
33        } else {
34            UIThemes::panel_unfocused()
35        };
36        let panel_block = Block::default()
37            .borders(Borders::ALL)
38            .border_type(if is_focused {
39                BorderType::Thick
40            } else {
41                BorderType::Plain
42            })
43            .title(format!(
44                "eBPF Trace Output ({} events)",
45                state.trace_events.len()
46            ))
47            .border_style(border_style);
48        frame.render_widget(panel_block, area);
49
50        if area.width <= 2 || area.height <= 2 {
51            return;
52        }
53        let content_area = Rect {
54            x: area.x + 1,
55            y: area.y + 1,
56            width: area.width - 2,
57            height: area.height - 2,
58        };
59        let content_width = content_area.width as usize;
60
61        // Build cards
62        struct Card {
63            header_no_bold: String,
64            header_number: String,
65            header_rest: String,
66            body_lines: Vec<Line<'static>>,
67            total_height: u16,
68            is_error: bool,
69            is_latest: bool,
70        }
71        let mut cards: Vec<Card> = Vec::new();
72
73        let total_traces = state.trace_events.len();
74        for (trace_index, cached_trace) in state.trace_events.iter().enumerate() {
75            let trace = &cached_trace.event;
76            let is_latest = trace_index == total_traces - 1;
77            let is_error = trace.is_error();
78
79            let header_no_bold = String::from("[No:");
80            let message_id = (trace_index + 1) as u64;
81            let formatted_timestamp = &cached_trace.formatted_timestamp;
82            let header_number = message_id.to_string();
83            let header_rest = format!(
84                "] {} TraceID:{} PID:{} TID:{}",
85                formatted_timestamp, trace.trace_id, trace.pid, trace.tid
86            );
87
88            let mut body_lines: Vec<Line> = Vec::new();
89            for item in &trace.items {
90                body_lines.extend(Self::render_trace_item(
91                    item,
92                    content_width,
93                    state.view_mode,
94                ));
95            }
96
97            // In list view: truncate to 3 body lines (with ellipsis) to keep card compact
98            let (body_for_display, inner_height): (Vec<Line>, u16) = match state.view_mode {
99                EbpfViewMode::List => {
100                    let mut b = Vec::new();
101                    if body_lines.is_empty() {
102                        b.push(Line::from(""));
103                    } else {
104                        let max_body = 3usize;
105                        let truncated = body_lines.len() > max_body;
106                        let take_n = body_lines.len().min(max_body);
107                        b.extend(body_lines.iter().take(take_n).cloned());
108                        if truncated {
109                            if let Some(last) = b.last_mut() {
110                                // Make ellipsis more eye-catching and prevent wrap from hiding it
111                                let ellipsis = Span::styled(
112                                    " …",
113                                    Style::default()
114                                        .fg(Color::Yellow)
115                                        .add_modifier(Modifier::BOLD),
116                                );
117                                if last.spans.len() >= 2 {
118                                    let indent = last.spans[0].content.clone();
119                                    let style = last.spans[1].style;
120                                    let original = last.spans[1].content.to_string();
121                                    // Reserve 2 characters (space + ellipsis) using char-safe trimming
122                                    let trimmed = Self::trim_chars_from_end(&original, 2);
123                                    last.spans.clear();
124                                    last.spans.push(Span::raw(indent));
125                                    last.spans.push(Span::styled(trimmed, style));
126                                    last.spans.push(ellipsis);
127                                } else {
128                                    last.spans.push(ellipsis);
129                                }
130                            }
131                        }
132                    }
133                    (b.clone(), u16::max(1, b.len() as u16))
134                }
135                EbpfViewMode::Expanded { .. } => {
136                    (body_lines.clone(), u16::max(1, body_lines.len() as u16))
137                }
138            };
139            let total_height = inner_height + 2;
140            cards.push(Card {
141                header_no_bold,
142                header_number,
143                header_rest,
144                body_lines: body_for_display,
145                total_height,
146                is_error,
147                is_latest,
148            });
149        }
150
151        // Expanded view: render only selected card full-screen with scroll
152        if let EbpfViewMode::Expanded { index, scroll } = state.view_mode {
153            if let Some(card) = cards.get(index) {
154                let border_style_l = Style::default().fg(Color::Green);
155                let title_color = Color::Green;
156                let card_block = Block::default()
157                    .borders(Borders::ALL)
158                    .border_type(BorderType::Thick)
159                    .border_style(border_style_l)
160                    .title(Line::from(vec![
161                        Span::styled(
162                            card.header_no_bold.clone(),
163                            Style::default().fg(title_color),
164                        ),
165                        Span::styled(
166                            card.header_number.clone(),
167                            Style::default()
168                                .fg(Color::LightMagenta)
169                                .add_modifier(Modifier::BOLD),
170                        ),
171                        Span::styled(card.header_rest.clone(), Style::default().fg(title_color)),
172                    ]));
173                frame.render_widget(card_block, content_area);
174
175                if content_area.width > 2 && content_area.height > 2 {
176                    // reserve 1 line for hint at bottom
177                    let hint_h: u16 = 1;
178                    let inner_h = content_area.height.saturating_sub(2 + hint_h);
179                    let inner = Rect {
180                        x: content_area.x + 1,
181                        y: content_area.y + 1,
182                        width: content_area.width - 2,
183                        height: inner_h,
184                    };
185                    // update last_inner_height for half-page scroll
186                    state.last_inner_height = inner.height as usize;
187                    let max_body_lines = inner.height as usize;
188                    let total = card.body_lines.len();
189                    let max_scroll = total.saturating_sub(max_body_lines);
190                    let start = scroll.min(max_scroll);
191                    let end = (start + max_body_lines).min(total);
192                    // Normalize scroll state to avoid accumulating beyond bounds
193                    if start != scroll {
194                        state.set_expanded_scroll(start);
195                    }
196                    let lines = card.body_lines[start..end].to_vec();
197                    let para = Paragraph::new(lines);
198                    frame.render_widget(para, inner);
199                    // hint
200                    let hint_rect = Rect {
201                        x: content_area.x + 1,
202                        y: content_area.y + content_area.height.saturating_sub(1),
203                        width: content_area.width.saturating_sub(2),
204                        height: 1,
205                    };
206                    let hint = "Esc/Ctrl+C to exit  •  j/k/↑/↓ scroll  •  Ctrl+U/D half-page  •  PgUp/PgDn page";
207                    let hint_line =
208                        Line::from(Span::styled(hint, Style::default().fg(Color::Gray)));
209                    let hint_para = Paragraph::new(vec![hint_line]);
210                    frame.render_widget(hint_para, hint_rect);
211                }
212            }
213            return;
214        }
215
216        // Determine start index based on mode (keep previous behavior)
217        let viewport_height = content_area.height;
218        let start_index = match state.display_mode {
219            DisplayMode::AutoRefresh => {
220                let mut accumulated: u16 = 0;
221                let mut idx = cards.len();
222                while idx > 0 {
223                    let next_height = accumulated.saturating_add(cards[idx - 1].total_height);
224                    if next_height > viewport_height {
225                        break;
226                    }
227                    accumulated = next_height;
228                    idx -= 1;
229                }
230                idx
231            }
232            DisplayMode::Scroll => {
233                let cursor = state.cursor_trace_index.min(cards.len().saturating_sub(1));
234                let mut height_below: u16 = 0;
235                let mut end = cursor;
236                while end < cards.len() {
237                    let card_height = cards[end].total_height;
238                    if height_below + card_height > viewport_height {
239                        break;
240                    }
241                    height_below += card_height;
242                    end += 1;
243                }
244
245                let mut height_above: u16 = 0;
246                let mut idx = cursor;
247                while idx > 0 {
248                    let card_height = cards[idx - 1].total_height;
249                    if height_above + height_below + card_height > viewport_height {
250                        break;
251                    }
252                    height_above += card_height;
253                    idx -= 1;
254                }
255                idx
256            }
257        };
258
259        // Render cards: clamp within viewport and keep order
260        let mut y = content_area.y;
261        for (idx, card) in cards.iter().enumerate().skip(start_index) {
262            if y >= content_area.y + content_area.height {
263                break;
264            }
265            // Clamp card height to remaining viewport to avoid rendering outside buffer
266            let remaining = (content_area.y + content_area.height).saturating_sub(y);
267            let height = card.total_height.min(remaining);
268            if height < 2 {
269                break;
270            }
271
272            let is_cursor = state.show_cursor && idx == state.cursor_trace_index;
273            let mut border_style_l = Style::default();
274            let mut border_type = BorderType::Plain;
275            if is_cursor {
276                border_style_l = Style::default().fg(Color::Yellow);
277                border_type = BorderType::Thick;
278            } else if card.is_latest {
279                border_style_l = Style::default().fg(Color::Green);
280                border_type = BorderType::Thick;
281            } else if card.is_error {
282                border_style_l = Style::default().fg(Color::Red);
283            }
284            let title_color = if is_cursor {
285                Color::Yellow
286            } else if card.is_latest {
287                Color::Green
288            } else {
289                Color::Gray
290            };
291
292            let card_block = Block::default()
293                .borders(Borders::ALL)
294                .border_type(border_type)
295                .border_style(border_style_l)
296                .title(Line::from(vec![
297                    Span::styled(
298                        card.header_no_bold.clone(),
299                        Style::default().fg(title_color),
300                    ),
301                    Span::styled(
302                        card.header_number.clone(),
303                        Style::default()
304                            .fg(Color::LightMagenta)
305                            .add_modifier(Modifier::BOLD),
306                    ),
307                    Span::styled(card.header_rest.clone(), Style::default().fg(title_color)),
308                ]));
309
310            let card_area = Rect {
311                x: content_area.x,
312                y,
313                width: content_area.width,
314                height,
315            };
316            frame.render_widget(card_block, card_area);
317
318            if card_area.width > 2 && card_area.height > 2 {
319                let inner = Rect {
320                    x: card_area.x + 1,
321                    y: card_area.y + 1,
322                    width: card_area.width - 2,
323                    height: card_area.height - 2,
324                };
325                let body = if card.body_lines.is_empty() {
326                    vec![Line::from("")]
327                } else {
328                    card.body_lines.clone()
329                };
330                let para = Paragraph::new(body);
331                frame.render_widget(para, inner);
332            }
333
334            y = y.saturating_add(height);
335        }
336
337        // Auxiliary hint (keep original behavior)
338        if state.g_pressed || state.numeric_prefix.is_some() {
339            let input_text = if let Some(ref s) = state.numeric_prefix {
340                s.clone()
341            } else {
342                "g".to_string()
343            };
344            let hint_text = if state.g_pressed && state.numeric_prefix.is_none() {
345                " Press 'g' again for top"
346            } else if state.numeric_prefix.is_some() {
347                " Press 'G' to jump to message"
348            } else {
349                ""
350            };
351            let full_text = if hint_text.is_empty() {
352                input_text.clone()
353            } else {
354                let hint_body = &hint_text[1..];
355                format!("{input_text} ({hint_body})")
356            };
357
358            let text_width = full_text.len() as u16;
359            let display_x = content_area.x + content_area.width.saturating_sub(text_width + 2);
360            let display_y = content_area.y + content_area.height.saturating_sub(1);
361
362            let mut spans = vec![Span::styled(
363                input_text,
364                Style::default().fg(Color::Green).bg(Color::Rgb(30, 30, 30)),
365            )];
366            if !hint_text.is_empty() {
367                let hint_body = &hint_text[1..];
368                spans.push(Span::styled(
369                    format!(" ({hint_body})"),
370                    Style::default()
371                        .fg(border_style.fg.unwrap_or(Color::White))
372                        .bg(Color::Rgb(30, 30, 30)),
373                ));
374            }
375            let text = ratatui::text::Text::from(ratatui::text::Line::from(spans));
376            frame.render_widget(
377                ratatui::widgets::Paragraph::new(text).alignment(ratatui::layout::Alignment::Right),
378                Rect::new(display_x, display_y, text_width + 2, 1),
379            );
380        }
381    }
382
383    /// Wrap text with different widths for first and continuation lines
384    fn wrap_text_with_widths(text: &str, first_width: usize, cont_width: usize) -> Vec<String> {
385        if text.is_empty() {
386            return vec![String::new()];
387        }
388
389        let fw = first_width.max(1);
390        let cw = cont_width.max(1);
391        let mut width = fw;
392        let mut lines = Vec::new();
393        let mut current_line = String::new();
394
395        for ch in text.chars() {
396            if ch == '\n' {
397                lines.push(current_line);
398                current_line = String::new();
399                width = cw; // after first explicit break, use continuation width
400                continue;
401            }
402            if current_line.len() >= width {
403                lines.push(std::mem::take(&mut current_line));
404                width = cw; // subsequent lines use continuation width
405            }
406            current_line.push(ch);
407        }
408
409        lines.push(current_line);
410        lines
411    }
412
413    /// Trim the last `n` characters from a UTF-8 string safely (by char boundary)
414    fn trim_chars_from_end(s: &str, n: usize) -> String {
415        if n == 0 || s.is_empty() {
416            return s.to_string();
417        }
418        let mut end = s.len();
419        let mut iter = s.char_indices().rev();
420        for _ in 0..n {
421            if let Some((idx, _)) = iter.next() {
422                end = idx;
423            } else {
424                end = 0;
425                break;
426            }
427        }
428        s[..end].to_string()
429    }
430
431    fn render_trace_item(
432        item: &TraceDisplayItem,
433        content_width: usize,
434        view_mode: EbpfViewMode,
435    ) -> Vec<Line<'static>> {
436        match item {
437            TraceDisplayItem::Text { content } => Self::render_text_item(content, content_width),
438            TraceDisplayItem::FormattedText { content } => {
439                Self::render_text_item(content, content_width)
440            }
441            TraceDisplayItem::Variable(variable) => {
442                Self::render_text_item(&variable.to_formatted_output(), content_width)
443            }
444            TraceDisplayItem::ComplexVariable(variable) => {
445                Self::render_text_item(&variable.to_formatted_output(), content_width)
446            }
447            TraceDisplayItem::ExprError(error) => {
448                Self::render_text_item(&error.to_formatted_output(), content_width)
449            }
450            TraceDisplayItem::Backtrace(backtrace) => Self::render_backtrace_item(
451                backtrace,
452                matches!(view_mode, EbpfViewMode::Expanded { .. }),
453                content_width,
454            ),
455        }
456    }
457
458    fn render_text_item(content: &str, content_width: usize) -> Vec<Line<'static>> {
459        let color = if content.contains("ERROR") || content.contains("Error") {
460            Color::Red
461        } else if content.contains("WARN") || content.contains("Warning") {
462            Color::Yellow
463        } else {
464            Color::Cyan
465        };
466        let inner_width = content_width.saturating_sub(2);
467        let first_width = inner_width.saturating_sub(2);
468        let cont_width = inner_width.saturating_sub(4);
469        Self::wrap_text_with_widths(content, first_width, cont_width)
470            .into_iter()
471            .enumerate()
472            .map(|(i, seg)| {
473                let line_indent = if i == 0 { "  " } else { "    " };
474                Line::from(vec![
475                    Span::raw(line_indent),
476                    Span::styled(seg, Style::default().fg(color)),
477                ])
478            })
479            .collect()
480    }
481
482    fn render_backtrace_item(
483        backtrace: &BacktraceDisplay,
484        expanded: bool,
485        content_width: usize,
486    ) -> Vec<Line<'static>> {
487        let line_width = content_width.saturating_sub(2).max(1);
488        let mut lines = Vec::new();
489        lines.push(Self::render_backtrace_header(backtrace));
490
491        if expanded {
492            lines.extend(backtrace.frames.iter().map(Self::render_backtrace_frame));
493            if let Some(stopped) = backtrace.stopped_text() {
494                lines.push(Line::from(vec![
495                    Span::raw("  "),
496                    Span::styled(stopped, Self::status_style(backtrace.status)),
497                ]));
498            }
499            return lines
500                .into_iter()
501                .flat_map(|line| Self::wrap_styled_line(line, line_width, "    "))
502                .collect();
503        } else if backtrace.frames.len() > 2 {
504            if let Some(first) = backtrace.frames.first() {
505                let first_frame =
506                    Self::wrap_styled_line(Self::render_backtrace_frame(first), line_width, "    ");
507                if let Some(first_line) = first_frame.into_iter().next() {
508                    lines.push(first_line);
509                }
510            }
511            lines.push(Self::render_backtrace_more_line(
512                backtrace.frames.len().saturating_sub(1),
513            ));
514            return lines;
515        } else {
516            lines.extend(backtrace.frames.iter().map(Self::render_backtrace_frame));
517            if lines.len() < 3 {
518                if let Some(stopped) = backtrace.stopped_text() {
519                    lines.push(Line::from(vec![
520                        Span::raw("  "),
521                        Span::styled(stopped, Self::status_style(backtrace.status)),
522                    ]));
523                }
524            }
525        }
526
527        lines
528            .into_iter()
529            .flat_map(|line| Self::wrap_styled_line(line, line_width, "    "))
530            .collect()
531    }
532
533    fn wrap_styled_line(
534        line: Line<'static>,
535        width: usize,
536        continuation_indent: &'static str,
537    ) -> Vec<Line<'static>> {
538        let width = width.max(1);
539        let indent_width = continuation_indent.chars().count();
540        if width <= indent_width {
541            return vec![line];
542        }
543
544        let mut wrapped = Vec::new();
545        let mut current = Vec::new();
546        let mut current_len = 0usize;
547        let mut continuation = false;
548
549        for span in line.spans {
550            let style = span.style;
551            let mut remaining = span.content.into_owned();
552            while !remaining.is_empty() {
553                if current_len >= width {
554                    wrapped.push(Line::from(current));
555                    current = vec![Span::raw(continuation_indent)];
556                    current_len = indent_width;
557                    continuation = true;
558                }
559
560                let available = width.saturating_sub(current_len);
561                if available == 0 {
562                    wrapped.push(Line::from(current));
563                    current = vec![Span::raw(continuation_indent)];
564                    current_len = indent_width;
565                    continuation = true;
566                    continue;
567                }
568
569                let (segment, rest) = Self::split_prefix_chars(&remaining, available);
570                current_len += segment.chars().count();
571                current.push(Span::styled(segment, style));
572                remaining = rest;
573            }
574        }
575
576        if current.is_empty() {
577            if continuation {
578                wrapped.push(Line::from(vec![Span::raw(continuation_indent)]));
579            }
580        } else {
581            wrapped.push(Line::from(current));
582        }
583        wrapped
584    }
585
586    fn split_prefix_chars(text: &str, max_chars: usize) -> (String, String) {
587        if max_chars == 0 {
588            return (String::new(), text.to_string());
589        }
590
591        let mut split = text.len();
592        for (count, (idx, _)) in text.char_indices().enumerate() {
593            if count == max_chars {
594                split = idx;
595                break;
596            }
597        }
598        (text[..split].to_string(), text[split..].to_string())
599    }
600
601    fn render_backtrace_header(backtrace: &BacktraceDisplay) -> Line<'static> {
602        let frame_word = if backtrace.physical_frame_count == 1 {
603            "frame"
604        } else {
605            "frames"
606        };
607        let mut spans = vec![
608            Span::raw("  "),
609            Span::styled(
610                "backtrace",
611                Style::default()
612                    .fg(Color::LightBlue)
613                    .add_modifier(Modifier::BOLD),
614            ),
615            Span::styled(": ", Style::default().fg(Color::Gray)),
616            Span::styled(
617                backtrace.status.label().to_string(),
618                Self::status_style(backtrace.status),
619            ),
620            Span::styled(", ", Style::default().fg(Color::Gray)),
621            Span::styled(
622                backtrace.physical_frame_count.to_string(),
623                Style::default()
624                    .fg(Color::White)
625                    .add_modifier(Modifier::BOLD),
626            ),
627            Span::styled(format!(" {frame_word}"), Style::default().fg(Color::Gray)),
628            Span::styled(
629                format!(" (max {})", backtrace.requested_depth),
630                Style::default().fg(Color::DarkGray),
631            ),
632        ];
633        if backtrace.raw {
634            spans.push(Span::styled(" raw", Style::default().fg(Color::Yellow)));
635        }
636        Line::from(spans)
637    }
638
639    fn render_backtrace_frame(frame: &BacktraceDisplayFrame) -> Line<'static> {
640        let mut spans = vec![
641            Span::raw("  "),
642            Span::styled(
643                format!("#{}", frame.index),
644                Style::default()
645                    .fg(Color::LightBlue)
646                    .add_modifier(Modifier::BOLD),
647            ),
648        ];
649        if frame.inline {
650            spans.push(Span::styled(
651                ".inline",
652                Style::default().fg(Color::LightBlue),
653            ));
654        }
655        spans.push(Span::raw(" "));
656
657        if let Some(function) = &frame.function {
658            spans.push(Span::styled(
659                function.clone(),
660                Style::default()
661                    .fg(Color::White)
662                    .add_modifier(Modifier::BOLD),
663            ));
664            if !frame.parameters.is_empty() {
665                spans.push(Span::styled("(", Style::default().fg(Color::Gray)));
666                for (idx, parameter) in frame.parameters.iter().enumerate() {
667                    if idx > 0 {
668                        spans.push(Span::styled(", ", Style::default().fg(Color::Gray)));
669                    }
670                    spans.extend(Self::render_parameter_spans(parameter));
671                }
672                spans.push(Span::styled(")", Style::default().fg(Color::Gray)));
673            }
674        } else {
675            spans.push(Span::styled(
676                frame
677                    .address
678                    .clone()
679                    .unwrap_or_else(|| "<unknown function>".to_string()),
680                Style::default().fg(Color::Yellow),
681            ));
682        }
683
684        if let Some(location) = &frame.location {
685            spans.push(Span::styled(" at ", Style::default().fg(Color::Gray)));
686            spans.push(Span::styled(
687                location.clone(),
688                Style::default().fg(Color::Cyan),
689            ));
690        } else if frame.function.is_some() {
691            spans.push(Span::styled(" at ??", Style::default().fg(Color::DarkGray)));
692        }
693        spans.push(Span::styled(" [", Style::default().fg(Color::Gray)));
694        spans.push(Span::styled(
695            frame.module.clone(),
696            Style::default().fg(Color::LightYellow),
697        ));
698        spans.push(Span::styled("]", Style::default().fg(Color::Gray)));
699
700        if let Some(raw_ip) = frame.raw_ip {
701            spans.push(Span::styled(
702                format!(" raw=0x{raw_ip:x}"),
703                Style::default().fg(Color::DarkGray),
704            ));
705        }
706        if let Some(cookie) = frame.cookie {
707            spans.push(Span::styled(
708                format!(" cookie=0x{cookie:016x}"),
709                Style::default().fg(Color::DarkGray),
710            ));
711        }
712        if let Some(flags) = frame.flags {
713            spans.push(Span::styled(
714                format!(" flags=0x{flags:x}"),
715                Style::default().fg(Color::DarkGray),
716            ));
717        }
718
719        Line::from(spans)
720    }
721
722    fn render_backtrace_more_line(hidden_frames: usize) -> Line<'static> {
723        let frame_word = if hidden_frames == 1 {
724            "frame"
725        } else {
726            "frames"
727        };
728        Line::from(vec![
729            Span::raw("  "),
730            Span::styled(
731                format!("... {hidden_frames} more {frame_word}"),
732                Style::default().fg(Color::DarkGray),
733            ),
734        ])
735    }
736
737    fn render_parameter_spans(parameter: &str) -> Vec<Span<'static>> {
738        let parameter = parameter.trim();
739        if parameter.is_empty() {
740            return Vec::new();
741        }
742
743        if let Some((type_name, name)) = parameter.rsplit_once(' ') {
744            if !type_name.trim().is_empty() && !name.trim().is_empty() {
745                return vec![
746                    Span::styled(
747                        type_name.trim().to_string(),
748                        Style::default().fg(Color::LightMagenta),
749                    ),
750                    Span::raw(" "),
751                    Span::styled(
752                        name.trim().to_string(),
753                        Style::default()
754                            .fg(Color::Green)
755                            .add_modifier(Modifier::BOLD),
756                    ),
757                ];
758            }
759        }
760
761        vec![Span::styled(
762            parameter.to_string(),
763            Style::default().fg(Color::LightMagenta),
764        )]
765    }
766
767    fn status_style(status: BacktraceStatus) -> Style {
768        match status {
769            BacktraceStatus::Complete => Style::default()
770                .fg(Color::Green)
771                .add_modifier(Modifier::BOLD),
772            BacktraceStatus::Truncated
773            | BacktraceStatus::DwarfUnavailable
774            | BacktraceStatus::UnsupportedCfi
775            | BacktraceStatus::NoUnwindRowsForPc
776            | BacktraceStatus::OffsetsUnavailable => Style::default()
777                .fg(Color::Yellow)
778                .add_modifier(Modifier::BOLD),
779            BacktraceStatus::ReadError
780            | BacktraceStatus::InternalError
781            | BacktraceStatus::InvalidFrame => {
782                Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
783            }
784        }
785    }
786}
787
788impl Default for EbpfPanelRenderer {
789    fn default() -> Self {
790        Self::new()
791    }
792}
793
794#[cfg(test)]
795mod tests {
796    use super::*;
797
798    fn line_text(line: &Line<'_>) -> String {
799        line.spans
800            .iter()
801            .map(|span| span.content.as_ref())
802            .collect()
803    }
804
805    fn sample_backtrace(frame_count: usize) -> BacktraceDisplay {
806        BacktraceDisplay {
807            requested_depth: 128,
808            physical_frame_count: frame_count,
809            status: BacktraceStatus::Complete,
810            error_code: 0,
811            raw: false,
812            frames: (0..frame_count)
813                .map(|index| BacktraceDisplayFrame {
814                    index,
815                    inline: false,
816                    function: Some(format!("function_{index}")),
817                    parameters: vec!["ngx_http_request_s* r".to_string()],
818                    address: None,
819                    location: Some(format!("request.c:{}", 100 + index)),
820                    module: format!("nginx+0x{:x}", 0x1000 + index),
821                    raw_ip: None,
822                    cookie: None,
823                    flags: None,
824                })
825                .collect(),
826        }
827    }
828
829    #[test]
830    fn list_mode_renders_backtrace_as_compact_structured_item() {
831        let item = TraceDisplayItem::Backtrace(sample_backtrace(4));
832        let lines = EbpfPanelRenderer::render_trace_item(&item, 120, EbpfViewMode::List);
833
834        assert_eq!(lines.len(), 3);
835        assert!(line_text(&lines[0]).contains("backtrace: complete, 4 frames"));
836        assert!(line_text(&lines[1]).contains("#0 function_0"));
837        assert!(line_text(&lines[2]).contains("... 3 more frames"));
838    }
839
840    #[test]
841    fn list_mode_keeps_backtrace_summary_when_first_frame_wraps() {
842        let mut backtrace = sample_backtrace(4);
843        backtrace.frames[0].function =
844            Some("ngx_http_process_request_headers_with_a_long_suffix".to_string());
845        backtrace.frames[0].parameters = vec!["ngx_http_request_s* request".to_string()];
846        backtrace.frames[0].location = Some(
847            "/mnt/500g/code/openresty/openresty-1.27.1.1/build/nginx/src/http/ngx_http_request.c:1529:13"
848                .to_string(),
849        );
850
851        let item = TraceDisplayItem::Backtrace(backtrace);
852        let lines = EbpfPanelRenderer::render_trace_item(&item, 48, EbpfViewMode::List);
853
854        assert_eq!(lines.len(), 3);
855        assert!(line_text(&lines[0]).contains("backtrace: complete, 4 frames"));
856        assert!(line_text(&lines[1]).contains("#0 ngx_http_process"));
857        assert!(line_text(&lines[2]).contains("... 3 more frames"));
858    }
859
860    #[test]
861    fn expanded_mode_keeps_backtrace_status_and_parameters_structured() {
862        let item = TraceDisplayItem::Backtrace(sample_backtrace(1));
863        let lines = EbpfPanelRenderer::render_trace_item(
864            &item,
865            120,
866            EbpfViewMode::Expanded {
867                index: 0,
868                scroll: 0,
869            },
870        );
871
872        let frame = line_text(&lines[1]);
873        assert!(frame.contains("function_0("));
874        assert!(frame.contains("ngx_http_request_s* r"));
875        assert!(frame.contains("request.c:100"));
876        assert!(frame.contains("[nginx+0x1000]"));
877    }
878
879    #[test]
880    fn expanded_backtrace_lines_wrap_to_panel_width() {
881        let mut backtrace = sample_backtrace(1);
882        backtrace.frames[0].function =
883            Some("ngx_http_process_request_headers_with_a_long_suffix".to_string());
884        backtrace.frames[0].parameters = vec![
885            "ngx_http_request_s* request".to_string(),
886            "long unsigned int flags".to_string(),
887        ];
888        backtrace.frames[0].location = Some(
889            "/mnt/500g/code/openresty/openresty-1.27.1.1/build/nginx/src/http/ngx_http_request.c:1529:13"
890                .to_string(),
891        );
892
893        let item = TraceDisplayItem::Backtrace(backtrace);
894        let lines = EbpfPanelRenderer::render_trace_item(
895            &item,
896            48,
897            EbpfViewMode::Expanded {
898                index: 0,
899                scroll: 0,
900            },
901        );
902
903        assert!(
904            lines.len() > 2,
905            "narrow backtrace output should wrap long frame lines"
906        );
907        assert!(line_text(&lines[1]).contains("#0 ngx_http_process"));
908        assert!(
909            lines
910                .iter()
911                .skip(2)
912                .map(line_text)
913                .any(|line| line.starts_with("    ") && line.contains("request")),
914            "wrapped continuation should keep parameter text with indentation"
915        );
916        assert!(
917            lines
918                .iter()
919                .map(line_text)
920                .any(|line| line.contains("ngx_http_request.c:1529:13")),
921            "wrapped continuation should retain the source location"
922        );
923    }
924
925    #[test]
926    fn header_uses_physical_frame_count_for_inline_backtraces() {
927        let mut backtrace = sample_backtrace(1);
928        let mut inline_frame = backtrace.frames[0].clone();
929        inline_frame.inline = true;
930        inline_frame.function = Some("inlined_add".to_string());
931        backtrace.frames.insert(0, inline_frame);
932
933        let item = TraceDisplayItem::Backtrace(backtrace);
934        let lines = EbpfPanelRenderer::render_trace_item(
935            &item,
936            120,
937            EbpfViewMode::Expanded {
938                index: 0,
939                scroll: 0,
940            },
941        );
942
943        let header = line_text(&lines[0]);
944        assert!(header.contains("backtrace: complete, 1 frame (max 128)"));
945        assert!(!header.contains("2 frames"));
946        assert!(line_text(&lines[1]).contains("#0.inline inlined_add"));
947        assert!(line_text(&lines[2]).contains("#0 function_0"));
948    }
949}