diff --git a/ghostscope-ui/src/components/app/event_loop.rs b/ghostscope-ui/src/components/app/event_loop.rs index 76c7bf0c..8529bf64 100644 --- a/ghostscope-ui/src/components/app/event_loop.rs +++ b/ghostscope-ui/src/components/app/event_loop.rs @@ -8,15 +8,24 @@ use crossterm::{ terminal::{disable_raw_mode, LeaveAlternateScreen}, }; use futures_util::StreamExt; +use tokio::time::{Duration, Instant}; use tracing::debug; +const FRAME_INTERVAL: Duration = Duration::from_nanos(1_000_000_000 / 60); +const TRACE_BATCH_SIZE: usize = 256; +const STATUS_BATCH_SIZE: usize = 64; + impl App { pub async fn run(&mut self) -> Result<()> { debug!("Starting new TEA-based TUI application"); // Create async event stream (proper crossterm async support) let mut event_stream = EventStream::new(); - let mut needs_render = true; + let mut needs_render = false; + let mut trace_batch = Vec::with_capacity(TRACE_BATCH_SIZE); + let mut status_batch = Vec::with_capacity(STATUS_BATCH_SIZE); + let mut trace_channel_open = true; + let mut status_channel_open = true; // Create a timeout for loading - if no runtime response, go to ready const LOADING_TIMEOUT_SECS: u64 = 30; @@ -35,6 +44,7 @@ impl App { // Initial render self.terminal.draw(|f| Self::draw_ui(f, &mut self.state))?; + let mut next_render = Instant::now() + FRAME_INTERVAL; loop { // Handle events using select! to monitor multiple sources @@ -58,18 +68,26 @@ impl App { } } - // Handle runtime status messages - Some(status) = self.state.event_registry.status_receiver.recv() => { - self.handle_runtime_status(status).await; - needs_render = true; + // Bound each batch so terminal input and timers get another turn. + count = self.state.event_registry.status_receiver.recv_many(&mut status_batch, STATUS_BATCH_SIZE), if status_channel_open => { + status_channel_open = count != 0; + for status in status_batch.drain(..) { + self.handle_runtime_status(status).await; + } + needs_render |= count != 0; } - // Handle trace events - Some(trace_event) = self.state.event_registry.trace_receiver.recv() => { - self.handle_trace_event(trace_event).await; - needs_render = true; + count = self.state.event_registry.trace_receiver.recv_many(&mut trace_batch, TRACE_BATCH_SIZE), if trace_channel_open => { + trace_channel_open = count != 0; + for trace_event in trace_batch.drain(..) { + self.handle_trace_event(trace_event).await; + } + needs_render |= count != 0; } + // Flush a pending frame even when a burst ends before the deadline. + () = tokio::time::sleep_until(next_render), if needs_render => {} + // Loading timeout - show error in loading UI () = &mut loading_timeout, if !self.state.loading_state.is_ready() && !self.state.loading_state.is_failed() => { tracing::info!("No runtime response after {} seconds, connection timeout", LOADING_TIMEOUT_SECS); @@ -112,16 +130,19 @@ impl App { } } - // Render only when needed (event-driven) - if needs_render { - self.terminal.draw(|f| Self::draw_ui(f, &mut self.state))?; - needs_render = false; - } - // Check for quit condition if self.should_quit || self.state.should_quit { break; } + + // Check after every batch as well as timer wakeups so a busy queue + // cannot starve rendering. Start the next interval after the draw to + // avoid catch-up frames when rendering or event handling is slow. + if needs_render && Instant::now() >= next_render { + self.terminal.draw(|f| Self::draw_ui(f, &mut self.state))?; + needs_render = false; + next_render = Instant::now() + FRAME_INTERVAL; + } } // Send shutdown command to runtime before cleanup diff --git a/ghostscope-ui/src/components/ebpf_panel/renderer.rs b/ghostscope-ui/src/components/ebpf_panel/renderer.rs index d567b93e..36a95fbb 100644 --- a/ghostscope-ui/src/components/ebpf_panel/renderer.rs +++ b/ghostscope-ui/src/components/ebpf_panel/renderer.rs @@ -1,5 +1,5 @@ use crate::events::{BacktraceDisplay, BacktraceDisplayFrame, TraceDisplayItem}; -use crate::model::panel_state::{DisplayMode, EbpfPanelState, EbpfViewMode}; +use crate::model::panel_state::{CachedTraceEvent, DisplayMode, EbpfPanelState, EbpfViewMode}; use crate::ui::themes::UIThemes; use ghostscope_protocol::trace_event::BacktraceStatus; use ratatui::{ @@ -9,11 +9,23 @@ use ratatui::{ widgets::{Block, BorderType, Borders, Paragraph}, Frame, }; +use std::collections::VecDeque; /// Renders the eBPF output panel #[derive(Debug)] pub struct EbpfPanelRenderer; +struct Card { + trace_index: usize, + header_no_bold: String, + header_number: String, + header_rest: String, + body_lines: Vec>, + total_height: usize, + is_error: bool, + is_latest: bool, +} + impl EbpfPanelRenderer { pub fn new() -> Self { Self @@ -58,99 +70,20 @@ impl EbpfPanelRenderer { }; let content_width = content_area.width as usize; - // Build cards - struct Card { - header_no_bold: String, - header_number: String, - header_rest: String, - body_lines: Vec>, - total_height: u16, - is_error: bool, - is_latest: bool, - } - let mut cards: Vec = Vec::new(); - let total_traces = state.trace_events.len(); - for (trace_index, cached_trace) in state.trace_events.iter().enumerate() { - let trace = &cached_trace.event; - let is_latest = trace_index == total_traces - 1; - let is_error = trace.is_error(); - - let header_no_bold = String::from("[No:"); - let message_id = (trace_index + 1) as u64; - let formatted_timestamp = &cached_trace.formatted_timestamp; - let header_number = message_id.to_string(); - let header_rest = format!( - "] {} TraceID:{} PID:{} TID:{}", - formatted_timestamp, trace.trace_id, trace.pid, trace.tid - ); - - let mut body_lines: Vec = Vec::new(); - for item in &trace.items { - body_lines.extend(Self::render_trace_item( - item, - content_width, - state.view_mode, - )); - } - - // In list view: truncate to 3 body lines (with ellipsis) to keep card compact - let (body_for_display, inner_height): (Vec, u16) = match state.view_mode { - EbpfViewMode::List => { - let mut b = Vec::new(); - if body_lines.is_empty() { - b.push(Line::from("")); - } else { - let max_body = 3usize; - let truncated = body_lines.len() > max_body; - let take_n = body_lines.len().min(max_body); - b.extend(body_lines.iter().take(take_n).cloned()); - if truncated { - if let Some(last) = b.last_mut() { - // Make ellipsis more eye-catching and prevent wrap from hiding it - let ellipsis = Span::styled( - " …", - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ); - if last.spans.len() >= 2 { - let indent = last.spans[0].content.clone(); - let style = last.spans[1].style; - let original = last.spans[1].content.to_string(); - // Reserve 2 characters (space + ellipsis) using char-safe trimming - let trimmed = Self::trim_chars_from_end(&original, 2); - last.spans.clear(); - last.spans.push(Span::raw(indent)); - last.spans.push(Span::styled(trimmed, style)); - last.spans.push(ellipsis); - } else { - last.spans.push(ellipsis); - } - } - } - } - (b.clone(), u16::max(1, b.len() as u16)) - } - EbpfViewMode::Expanded { .. } => { - (body_lines.clone(), u16::max(1, body_lines.len() as u16)) - } - }; - let total_height = inner_height + 2; - cards.push(Card { - header_no_bold, - header_number, - header_rest, - body_lines: body_for_display, - total_height, - is_error, - is_latest, - }); - } + let cards = Self::visible_cards(state, content_area.height, |index| { + Self::build_card( + &state.trace_events[index], + index, + total_traces, + content_width, + state.view_mode, + ) + }); // Expanded view: render only selected card full-screen with scroll - if let EbpfViewMode::Expanded { index, scroll } = state.view_mode { - if let Some(card) = cards.get(index) { + if let EbpfViewMode::Expanded { scroll, .. } = state.view_mode { + if let Some(card) = cards.front() { let border_style_l = Style::default().fg(Color::Green); let title_color = Color::Green; let card_block = Block::default() @@ -213,63 +146,20 @@ impl EbpfPanelRenderer { return; } - // Determine start index based on mode (keep previous behavior) - let viewport_height = content_area.height; - let start_index = match state.display_mode { - DisplayMode::AutoRefresh => { - let mut accumulated: u16 = 0; - let mut idx = cards.len(); - while idx > 0 { - let next_height = accumulated.saturating_add(cards[idx - 1].total_height); - if next_height > viewport_height { - break; - } - accumulated = next_height; - idx -= 1; - } - idx - } - DisplayMode::Scroll => { - let cursor = state.cursor_trace_index.min(cards.len().saturating_sub(1)); - let mut height_below: u16 = 0; - let mut end = cursor; - while end < cards.len() { - let card_height = cards[end].total_height; - if height_below + card_height > viewport_height { - break; - } - height_below += card_height; - end += 1; - } - - let mut height_above: u16 = 0; - let mut idx = cursor; - while idx > 0 { - let card_height = cards[idx - 1].total_height; - if height_above + height_below + card_height > viewport_height { - break; - } - height_above += card_height; - idx -= 1; - } - idx - } - }; - // Render cards: clamp within viewport and keep order let mut y = content_area.y; - for (idx, card) in cards.iter().enumerate().skip(start_index) { + for card in cards { if y >= content_area.y + content_area.height { break; } // Clamp card height to remaining viewport to avoid rendering outside buffer let remaining = (content_area.y + content_area.height).saturating_sub(y); - let height = card.total_height.min(remaining); + let height = card.total_height.min(remaining as usize) as u16; if height < 2 { break; } - let is_cursor = state.show_cursor && idx == state.cursor_trace_index; + let is_cursor = state.show_cursor && card.trace_index == state.cursor_trace_index; let mut border_style_l = Style::default(); let mut border_type = BorderType::Plain; if is_cursor { @@ -325,7 +215,7 @@ impl EbpfPanelRenderer { let body = if card.body_lines.is_empty() { vec![Line::from("")] } else { - card.body_lines.clone() + card.body_lines }; let para = Paragraph::new(body); frame.render_widget(para, inner); @@ -380,6 +270,151 @@ impl EbpfPanelRenderer { } } + /// Walk outward from the latest event or cursor, formatting only cards near + /// the viewport. Variable heights require at most one lookahead on each side. + fn visible_cards( + state: &EbpfPanelState, + viewport_height: u16, + mut build_card: impl FnMut(usize) -> Card, + ) -> VecDeque { + let mut cards = VecDeque::new(); + if let EbpfViewMode::Expanded { index, .. } = state.view_mode { + if index < state.trace_events.len() { + cards.push_back(build_card(index)); + } + return cards; + } + + let mut remaining = viewport_height as usize; + if remaining < 2 || state.trace_events.is_empty() { + return cards; + } + + match state.display_mode { + DisplayMode::AutoRefresh => { + for index in (0..state.trace_events.len()).rev() { + let card = build_card(index); + if card.total_height > remaining { + // Keep the latest event visible even in a short panel. + if cards.is_empty() { + cards.push_front(card); + } + break; + } + remaining -= card.total_height; + cards.push_front(card); + if remaining < 3 { + break; + } + } + } + DisplayMode::Scroll => { + let cursor = state.cursor_trace_index.min(state.trace_events.len() - 1); + let mut partial_card = None; + for index in cursor..state.trace_events.len() { + if remaining < 2 { + break; + } + let card = build_card(index); + if card.total_height > remaining { + if cards.is_empty() { + // A short panel must still show the selected card. + cards.push_back(card); + return cards; + } + partial_card = Some(card); + break; + } + remaining -= card.total_height; + cards.push_back(card); + } + + // Fill spare space above the cursor with whole cards, preserving + // the existing preference for showing newer events below it. + for index in (0..cursor).rev() { + if remaining < 3 { + break; + } + let card = build_card(index); + if card.total_height > remaining { + break; + } + remaining -= card.total_height; + cards.push_front(card); + } + if remaining >= 2 { + cards.extend(partial_card); + } + } + } + cards + } + + fn build_card( + cached_trace: &CachedTraceEvent, + trace_index: usize, + total_traces: usize, + content_width: usize, + view_mode: EbpfViewMode, + ) -> Card { + let trace = &cached_trace.event; + const MAX_LIST_BODY_LINES: usize = 3; + let line_limit = match view_mode { + EbpfViewMode::List => MAX_LIST_BODY_LINES + 1, + EbpfViewMode::Expanded { .. } => usize::MAX, + }; + let mut body_lines: Vec<_> = trace + .items + .iter() + .flat_map(|item| Self::render_trace_item(item, content_width, view_mode)) + .take(line_limit) + .collect(); + + if view_mode == EbpfViewMode::List { + let truncated = body_lines.len() > MAX_LIST_BODY_LINES; + body_lines.truncate(MAX_LIST_BODY_LINES); + if body_lines.is_empty() { + body_lines.push(Line::from("")); + } + if truncated { + if let Some(last) = body_lines.last_mut() { + let ellipsis = Span::styled( + " …", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ); + if last.spans.len() >= 2 { + let indent = last.spans[0].content.clone(); + let style = last.spans[1].style; + // Reserve space for the ellipsis without splitting UTF-8. + let trimmed = Self::trim_chars_from_end(&last.spans[1].content, 2); + last.spans.clear(); + last.spans.push(Span::raw(indent)); + last.spans.push(Span::styled(trimmed, style)); + last.spans.push(ellipsis); + } else { + last.spans.push(ellipsis); + } + } + } + } + + Card { + trace_index, + header_no_bold: String::from("[No:"), + header_number: (trace_index + 1).to_string(), + header_rest: format!( + "] {} TraceID:{} PID:{} TID:{}", + cached_trace.formatted_timestamp, trace.trace_id, trace.pid, trace.tid + ), + total_height: body_lines.len().max(1) + 2, + body_lines, + is_error: trace.is_error(), + is_latest: trace_index + 1 == total_traces, + } + } + /// Wrap text with different widths for first and continuation lines fn wrap_text_with_widths(text: &str, first_width: usize, cont_width: usize) -> Vec { if text.is_empty() { @@ -794,6 +829,198 @@ impl Default for EbpfPanelRenderer { #[cfg(test)] mod tests { use super::*; + use crate::events::UiTraceEvent; + use ratatui::{backend::TestBackend, buffer::Buffer, Terminal}; + + fn panel_with_messages(messages: &[&str]) -> EbpfPanelState { + let mut state = EbpfPanelState::new(); + for (index, message) in messages.iter().enumerate() { + state.add_trace_event(UiTraceEvent::text_event( + index as u64, + 1_000_000_000, + 123, + 456, + (*message).to_string(), + Some(0), + )); + } + state + } + + fn render_panel(state: &mut EbpfPanelState, width: u16, height: u16) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap(); + let mut renderer = EbpfPanelRenderer::new(); + terminal + .draw(|frame| renderer.render(state, frame, frame.area(), true)) + .unwrap(); + terminal.backend().buffer().clone() + } + + fn row_text(buffer: &Buffer, y: u16) -> String { + (0..buffer.area.width) + .map(|x| buffer[(x, y)].symbol()) + .collect() + } + + #[test] + fn formatting_work_is_bounded_by_viewport_in_all_modes() { + for count in [20, 2000] { + let mut state = panel_with_messages(&vec!["one\ntwo\nthree"; count]); + for cursor in [0, count / 2, count - 1] { + state.cursor_trace_index = cursor; + for mode in [DisplayMode::AutoRefresh, DisplayMode::Scroll] { + state.display_mode = mode; + for view in [ + EbpfViewMode::List, + EbpfViewMode::Expanded { + index: cursor, + scroll: 0, + }, + ] { + state.view_mode = view; + let mut formatted = Vec::new(); + let cards = EbpfPanelRenderer::visible_cards(&state, 18, |index| { + formatted.push(index); + EbpfPanelRenderer::build_card( + &state.trace_events[index], + index, + count, + 78, + view, + ) + }); + if matches!(view, EbpfViewMode::Expanded { .. }) { + assert_eq!(formatted, [cursor]); + assert_eq!(cards.len(), 1); + } else { + // Three full cards, a possible partial card, and + // at most one height lookahead in either direction. + assert!(formatted.len() <= 5, "{formatted:?}"); + let anchor = match mode { + DisplayMode::AutoRefresh => count - 1, + DisplayMode::Scroll => cursor, + }; + assert!(cards.iter().any(|card| card.trace_index == anchor)); + assert!(formatted.iter().all(|index| index.abs_diff(anchor) <= 4)); + } + } + } + } + } + } + + #[test] + fn auto_refresh_reflows_visible_cards_after_width_changes() { + let mut state = panel_with_messages(&["abcdefghijklmnopqrstuvwxyz"; 2]); + let wide = render_panel(&mut state, 60, 8); + assert!(row_text(&wide, 1).contains("[No:1]")); + assert!(row_text(&wide, 4).contains("[No:2]")); + assert_eq!(wide[(1, 4)].fg, Color::Green); + + let narrow = render_panel(&mut state, 15, 8); + assert!(row_text(&narrow, 1).contains("[No:2]")); + assert!(row_text(&narrow, 4).contains('…')); + + assert_eq!(render_panel(&mut state, 60, 8), wide); + } + + #[test] + fn scroll_view_preserves_variable_heights_and_partial_bottom_card() { + let mut state = panel_with_messages(&["one", "one\ntwo", "one\ntwo\nthree", "last"]); + state.display_mode = DisplayMode::Scroll; + state.cursor_trace_index = 1; + state.show_cursor = true; + + let filled_above = render_panel(&mut state, 80, 10); + assert!(row_text(&filled_above, 1).contains("[No:1]")); + assert!(row_text(&filled_above, 4).contains("[No:2]")); + assert_eq!(filled_above[(1, 4)].fg, Color::Yellow); + + let partial_below = render_panel(&mut state, 80, 13); + assert!(row_text(&partial_below, 1).contains("[No:2]")); + assert!(row_text(&partial_below, 5).contains("[No:3]")); + assert!(row_text(&partial_below, 10).contains("[No:4]")); + } + + #[test] + fn short_viewport_keeps_latest_or_selected_card_visible() { + let mut state = panel_with_messages(&["short", "one\ntwo\nthree", "last"]); + let latest = render_panel(&mut state, 80, 4); + assert!(row_text(&latest, 1).contains("[No:3]")); + + state.display_mode = DisplayMode::Scroll; + state.cursor_trace_index = 1; + let selected = render_panel(&mut state, 80, 5); + assert!(row_text(&selected, 1).contains("[No:2]")); + assert!(row_text(&selected, 2).contains("one")); + } + + #[test] + fn expanded_view_clamps_scroll_and_keeps_selected_header() { + let mut state = panel_with_messages(&[ + "hidden before", + "line 0\nline 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8", + "hidden after", + ]); + state.view_mode = EbpfViewMode::Expanded { + index: 1, + scroll: usize::MAX, + }; + let buffer = render_panel(&mut state, 100, 12); + assert!(row_text(&buffer, 1).contains("[No:2]")); + assert!(row_text(&buffer, 2).contains("line 2")); + assert!(row_text(&buffer, 8).contains("line 8")); + assert!(row_text(&buffer, 10).contains("Esc/Ctrl+C to exit")); + assert_eq!(state.expanded_scroll, 2); + assert_eq!(state.last_inner_height, 7); + } + + #[test] + fn list_preview_truncates_across_items_and_expanded_keeps_all_items() { + let mut state = panel_with_messages(&["unused"]); + state.trace_events[0].event.items = ["first", "second", "third", "fourth"] + .into_iter() + .map(|content| TraceDisplayItem::Text { + content: content.to_string(), + }) + .collect(); + let list = render_panel(&mut state, 80, 12); + assert!(row_text(&list, 4).contains("thi …")); + state.view_mode = EbpfViewMode::Expanded { + index: 0, + scroll: 0, + }; + let expanded = render_panel(&mut state, 80, 12); + assert!(row_text(&expanded, 4).contains("third")); + assert!(row_text(&expanded, 5).contains("fourth")); + } + + #[test] + fn empty_and_minimal_viewports_are_safe_in_all_modes() { + for mut state in [EbpfPanelState::new(), panel_with_messages(&["message"])] { + for width in [0, 1, 2, 3, 5, 80] { + for height in [0, 1, 2, 3, 4, 24] { + for mode in [DisplayMode::AutoRefresh, DisplayMode::Scroll] { + state.display_mode = mode; + for view in [ + EbpfViewMode::List, + EbpfViewMode::Expanded { + index: 0, + scroll: 0, + }, + EbpfViewMode::Expanded { + index: 10, + scroll: 0, + }, + ] { + state.view_mode = view; + render_panel(&mut state, width, height); + } + } + } + } + } + } fn line_text(line: &Line<'_>) -> String { line.spans