ghostscope_ui/components/ebpf_panel/
renderer.rs

1use crate::model::panel_state::{DisplayMode, EbpfPanelState, EbpfViewMode};
2use crate::ui::themes::UIThemes;
3use ratatui::{
4    layout::Rect,
5    style::{Color, Modifier, Style},
6    text::{Line, Span},
7    widgets::{Block, BorderType, Borders, Paragraph},
8    Frame,
9};
10
11/// Renders the eBPF output panel
12#[derive(Debug)]
13pub struct EbpfPanelRenderer;
14
15impl EbpfPanelRenderer {
16    pub fn new() -> Self {
17        Self
18    }
19
20    /// Render the eBPF panel
21    pub fn render(
22        &mut self,
23        state: &mut EbpfPanelState,
24        frame: &mut Frame,
25        area: Rect,
26        is_focused: bool,
27    ) {
28        // Outer panel block
29        let border_style = if is_focused {
30            UIThemes::panel_focused()
31        } else {
32            UIThemes::panel_unfocused()
33        };
34        let panel_block = Block::default()
35            .borders(Borders::ALL)
36            .border_type(if is_focused {
37                BorderType::Thick
38            } else {
39                BorderType::Plain
40            })
41            .title(format!(
42                "eBPF Trace Output ({} events)",
43                state.trace_events.len()
44            ))
45            .border_style(border_style);
46        frame.render_widget(panel_block, area);
47
48        if area.width <= 2 || area.height <= 2 {
49            return;
50        }
51        let content_area = Rect {
52            x: area.x + 1,
53            y: area.y + 1,
54            width: area.width - 2,
55            height: area.height - 2,
56        };
57        let content_width = content_area.width as usize;
58
59        // Build cards
60        struct Card {
61            header_no_bold: String,
62            header_number: String,
63            header_rest: String,
64            body_lines: Vec<Line<'static>>,
65            total_height: u16,
66            is_error: bool,
67            is_latest: bool,
68        }
69        let mut cards: Vec<Card> = Vec::new();
70
71        let total_traces = state.trace_events.len();
72        for (trace_index, cached_trace) in state.trace_events.iter().enumerate() {
73            let trace = &cached_trace.event;
74            let is_latest = trace_index == total_traces - 1;
75            let is_error = trace.instructions.last().is_some_and(|inst| {
76                if let ghostscope_protocol::ParsedInstruction::EndInstruction {
77                    execution_status,
78                    ..
79                } = inst
80                {
81                    *execution_status == 1 || *execution_status == 2
82                } else {
83                    false
84                }
85            });
86
87            let header_no_bold = String::from("[No:");
88            let message_id = (trace_index + 1) as u64;
89            let formatted_timestamp = &cached_trace.formatted_timestamp;
90            let header_number = message_id.to_string();
91            let header_rest = format!(
92                "] {} TraceID:{} PID:{} TID:{}",
93                formatted_timestamp, trace.trace_id, trace.pid, trace.tid
94            );
95
96            let mut body_lines: Vec<Line> = Vec::new();
97            for output_line in trace.to_formatted_output() {
98                let color = if output_line.contains("ERROR") || output_line.contains("Error") {
99                    Color::Red
100                } else if output_line.contains("WARN") || output_line.contains("Warning") {
101                    Color::Yellow
102                } else {
103                    Color::Cyan
104                };
105                // Compute wrapping widths based on card inner width and indent per line
106                let inner_width = content_width.saturating_sub(2);
107                let first_width = inner_width.saturating_sub(2); // first line indent "  "
108                let cont_width = inner_width.saturating_sub(4); // continuation indent "    "
109                let wrapped_lines =
110                    Self::wrap_text_with_widths(&output_line, first_width, cont_width);
111                for (i, seg) in wrapped_lines.into_iter().enumerate() {
112                    let line_indent = if i == 0 { "  " } else { "    " };
113                    body_lines.push(Line::from(vec![
114                        Span::raw(line_indent),
115                        Span::styled(
116                            seg,
117                            Style::default().fg(color).add_modifier(Modifier::empty()),
118                        ),
119                    ]));
120                }
121            }
122
123            // In list view: truncate to 3 body lines (with ellipsis) to keep card compact
124            let (body_for_display, inner_height): (Vec<Line>, u16) = match state.view_mode {
125                EbpfViewMode::List => {
126                    let mut b = Vec::new();
127                    if body_lines.is_empty() {
128                        b.push(Line::from(""));
129                    } else {
130                        let max_body = 3usize;
131                        let truncated = body_lines.len() > max_body;
132                        let take_n = body_lines.len().min(max_body);
133                        b.extend(body_lines.iter().take(take_n).cloned());
134                        if truncated {
135                            if let Some(last) = b.last_mut() {
136                                // Make ellipsis more eye-catching and prevent wrap from hiding it
137                                let ellipsis = Span::styled(
138                                    " …",
139                                    Style::default()
140                                        .fg(Color::Yellow)
141                                        .add_modifier(Modifier::BOLD),
142                                );
143                                if last.spans.len() >= 2 {
144                                    let indent = last.spans[0].content.clone();
145                                    let style = last.spans[1].style;
146                                    let original = last.spans[1].content.to_string();
147                                    // Reserve 2 characters (space + ellipsis) using char-safe trimming
148                                    let trimmed = Self::trim_chars_from_end(&original, 2);
149                                    last.spans.clear();
150                                    last.spans.push(Span::raw(indent));
151                                    last.spans.push(Span::styled(trimmed, style));
152                                    last.spans.push(ellipsis);
153                                } else {
154                                    last.spans.push(ellipsis);
155                                }
156                            }
157                        }
158                    }
159                    (b.clone(), u16::max(1, b.len() as u16))
160                }
161                EbpfViewMode::Expanded { .. } => {
162                    (body_lines.clone(), u16::max(1, body_lines.len() as u16))
163                }
164            };
165            let total_height = inner_height + 2;
166            cards.push(Card {
167                header_no_bold,
168                header_number,
169                header_rest,
170                body_lines: body_for_display,
171                total_height,
172                is_error,
173                is_latest,
174            });
175        }
176
177        // Expanded view: render only selected card full-screen with scroll
178        if let EbpfViewMode::Expanded { index, scroll } = state.view_mode {
179            if let Some(card) = cards.get(index) {
180                let border_style_l = Style::default().fg(Color::Green);
181                let title_color = Color::Green;
182                let card_block = Block::default()
183                    .borders(Borders::ALL)
184                    .border_type(BorderType::Thick)
185                    .border_style(border_style_l)
186                    .title(Line::from(vec![
187                        Span::styled(
188                            card.header_no_bold.clone(),
189                            Style::default().fg(title_color),
190                        ),
191                        Span::styled(
192                            card.header_number.clone(),
193                            Style::default()
194                                .fg(Color::LightMagenta)
195                                .add_modifier(Modifier::BOLD),
196                        ),
197                        Span::styled(card.header_rest.clone(), Style::default().fg(title_color)),
198                    ]));
199                frame.render_widget(card_block, content_area);
200
201                if content_area.width > 2 && content_area.height > 2 {
202                    // reserve 1 line for hint at bottom
203                    let hint_h: u16 = 1;
204                    let inner_h = content_area.height.saturating_sub(2 + hint_h);
205                    let inner = Rect {
206                        x: content_area.x + 1,
207                        y: content_area.y + 1,
208                        width: content_area.width - 2,
209                        height: inner_h,
210                    };
211                    // update last_inner_height for half-page scroll
212                    state.last_inner_height = inner.height as usize;
213                    let max_body_lines = inner.height as usize;
214                    let total = card.body_lines.len();
215                    let max_scroll = total.saturating_sub(max_body_lines);
216                    let start = scroll.min(max_scroll);
217                    let end = (start + max_body_lines).min(total);
218                    // Normalize scroll state to avoid accumulating beyond bounds
219                    if start != scroll {
220                        state.set_expanded_scroll(start);
221                    }
222                    let lines = card.body_lines[start..end].to_vec();
223                    let para = Paragraph::new(lines);
224                    frame.render_widget(para, inner);
225                    // hint
226                    let hint_rect = Rect {
227                        x: content_area.x + 1,
228                        y: content_area.y + content_area.height.saturating_sub(1),
229                        width: content_area.width.saturating_sub(2),
230                        height: 1,
231                    };
232                    let hint = "Esc/Ctrl+C to exit  •  j/k/↑/↓ scroll  •  Ctrl+U/D half-page  •  PgUp/PgDn page";
233                    let hint_line =
234                        Line::from(Span::styled(hint, Style::default().fg(Color::Gray)));
235                    let hint_para = Paragraph::new(vec![hint_line]);
236                    frame.render_widget(hint_para, hint_rect);
237                }
238            }
239            return;
240        }
241
242        // Determine start index based on mode (keep previous behavior)
243        let viewport_height = content_area.height;
244        let start_index = match state.display_mode {
245            DisplayMode::AutoRefresh => {
246                let mut accumulated: u16 = 0;
247                let mut idx = cards.len();
248                while idx > 0 {
249                    let next_height = accumulated.saturating_add(cards[idx - 1].total_height);
250                    if next_height > viewport_height {
251                        break;
252                    }
253                    accumulated = next_height;
254                    idx -= 1;
255                }
256                idx
257            }
258            DisplayMode::Scroll => {
259                let cursor = state.cursor_trace_index.min(cards.len().saturating_sub(1));
260                let mut height_below: u16 = 0;
261                let mut end = cursor;
262                while end < cards.len() {
263                    let card_height = cards[end].total_height;
264                    if height_below + card_height > viewport_height {
265                        break;
266                    }
267                    height_below += card_height;
268                    end += 1;
269                }
270
271                let mut height_above: u16 = 0;
272                let mut idx = cursor;
273                while idx > 0 {
274                    let card_height = cards[idx - 1].total_height;
275                    if height_above + height_below + card_height > viewport_height {
276                        break;
277                    }
278                    height_above += card_height;
279                    idx -= 1;
280                }
281                idx
282            }
283        };
284
285        // Render cards: clamp within viewport and keep order
286        let mut y = content_area.y;
287        for (idx, card) in cards.iter().enumerate().skip(start_index) {
288            if y >= content_area.y + content_area.height {
289                break;
290            }
291            // Clamp card height to remaining viewport to avoid rendering outside buffer
292            let remaining = (content_area.y + content_area.height).saturating_sub(y);
293            let height = card.total_height.min(remaining);
294            if height < 2 {
295                break;
296            }
297
298            let is_cursor = state.show_cursor && idx == state.cursor_trace_index;
299            let mut border_style_l = Style::default();
300            let mut border_type = BorderType::Plain;
301            if is_cursor {
302                border_style_l = Style::default().fg(Color::Yellow);
303                border_type = BorderType::Thick;
304            } else if card.is_latest {
305                border_style_l = Style::default().fg(Color::Green);
306                border_type = BorderType::Thick;
307            } else if card.is_error {
308                border_style_l = Style::default().fg(Color::Red);
309            }
310            let title_color = if is_cursor {
311                Color::Yellow
312            } else if card.is_latest {
313                Color::Green
314            } else {
315                Color::Gray
316            };
317
318            let card_block = Block::default()
319                .borders(Borders::ALL)
320                .border_type(border_type)
321                .border_style(border_style_l)
322                .title(Line::from(vec![
323                    Span::styled(
324                        card.header_no_bold.clone(),
325                        Style::default().fg(title_color),
326                    ),
327                    Span::styled(
328                        card.header_number.clone(),
329                        Style::default()
330                            .fg(Color::LightMagenta)
331                            .add_modifier(Modifier::BOLD),
332                    ),
333                    Span::styled(card.header_rest.clone(), Style::default().fg(title_color)),
334                ]));
335
336            let card_area = Rect {
337                x: content_area.x,
338                y,
339                width: content_area.width,
340                height,
341            };
342            frame.render_widget(card_block, card_area);
343
344            if card_area.width > 2 && card_area.height > 2 {
345                let inner = Rect {
346                    x: card_area.x + 1,
347                    y: card_area.y + 1,
348                    width: card_area.width - 2,
349                    height: card_area.height - 2,
350                };
351                let body = if card.body_lines.is_empty() {
352                    vec![Line::from("")]
353                } else {
354                    card.body_lines.clone()
355                };
356                let para = Paragraph::new(body);
357                frame.render_widget(para, inner);
358            }
359
360            y = y.saturating_add(height);
361        }
362
363        // Auxiliary hint (keep original behavior)
364        if state.g_pressed || state.numeric_prefix.is_some() {
365            let input_text = if let Some(ref s) = state.numeric_prefix {
366                s.clone()
367            } else {
368                "g".to_string()
369            };
370            let hint_text = if state.g_pressed && state.numeric_prefix.is_none() {
371                " Press 'g' again for top"
372            } else if state.numeric_prefix.is_some() {
373                " Press 'G' to jump to message"
374            } else {
375                ""
376            };
377            let full_text = if hint_text.is_empty() {
378                input_text.clone()
379            } else {
380                let hint_body = &hint_text[1..];
381                format!("{input_text} ({hint_body})")
382            };
383
384            let text_width = full_text.len() as u16;
385            let display_x = content_area.x + content_area.width.saturating_sub(text_width + 2);
386            let display_y = content_area.y + content_area.height.saturating_sub(1);
387
388            let mut spans = vec![Span::styled(
389                input_text,
390                Style::default().fg(Color::Green).bg(Color::Rgb(30, 30, 30)),
391            )];
392            if !hint_text.is_empty() {
393                let hint_body = &hint_text[1..];
394                spans.push(Span::styled(
395                    format!(" ({hint_body})"),
396                    Style::default()
397                        .fg(border_style.fg.unwrap_or(Color::White))
398                        .bg(Color::Rgb(30, 30, 30)),
399                ));
400            }
401            let text = ratatui::text::Text::from(ratatui::text::Line::from(spans));
402            frame.render_widget(
403                ratatui::widgets::Paragraph::new(text).alignment(ratatui::layout::Alignment::Right),
404                Rect::new(display_x, display_y, text_width + 2, 1),
405            );
406        }
407    }
408
409    /// Wrap text with different widths for first and continuation lines
410    fn wrap_text_with_widths(text: &str, first_width: usize, cont_width: usize) -> Vec<String> {
411        if text.is_empty() {
412            return vec![String::new()];
413        }
414
415        let fw = first_width.max(1);
416        let cw = cont_width.max(1);
417        let mut width = fw;
418        let mut lines = Vec::new();
419        let mut current_line = String::new();
420
421        for ch in text.chars() {
422            if ch == '\n' {
423                lines.push(current_line);
424                current_line = String::new();
425                width = cw; // after first explicit break, use continuation width
426                continue;
427            }
428            if current_line.len() >= width {
429                lines.push(std::mem::take(&mut current_line));
430                width = cw; // subsequent lines use continuation width
431            }
432            current_line.push(ch);
433        }
434
435        lines.push(current_line);
436        lines
437    }
438
439    /// Trim the last `n` characters from a UTF-8 string safely (by char boundary)
440    fn trim_chars_from_end(s: &str, n: usize) -> String {
441        if n == 0 || s.is_empty() {
442            return s.to_string();
443        }
444        let mut end = s.len();
445        let mut iter = s.char_indices().rev();
446        for _ in 0..n {
447            if let Some((idx, _)) = iter.next() {
448                end = idx;
449            } else {
450                end = 0;
451                break;
452            }
453        }
454        s[..end].to_string()
455    }
456}
457
458impl Default for EbpfPanelRenderer {
459    fn default() -> Self {
460        Self::new()
461    }
462}