Skip to main content

tui_dispatch_debug/debug/
widgets.rs

1//! Debug rendering utilities and widgets
2//!
3//! Provides theme-agnostic widgets for rendering debug information.
4//! Applications provide their own styles to customize appearance.
5
6use ratatui::buffer::Buffer;
7use ratatui::layout::{Constraint, Rect};
8use ratatui::style::{Modifier, Style};
9use ratatui::text::{Line, Span, Text};
10use ratatui::widgets::{Cell, Clear, Paragraph, Row, Table, Widget};
11use ratatui::Frame;
12
13use super::cell::{format_color_compact, format_modifier_compact, CellPreview};
14use super::config::DebugStyle;
15use super::table::{ActionLogOverlay, DebugTableOverlay, DebugTableRow};
16
17/// Convert a buffer to plain text (for clipboard export)
18///
19/// Trims trailing whitespace from each line.
20pub fn buffer_to_text(buffer: &Buffer) -> String {
21    let area = buffer.area;
22    let mut out = String::new();
23
24    for y in area.y..area.y.saturating_add(area.height) {
25        let mut line = String::new();
26        for x in area.x..area.x.saturating_add(area.width) {
27            line.push_str(buffer[(x, y)].symbol());
28        }
29        out.push_str(line.trim_end_matches(' '));
30        if y + 1 < area.y.saturating_add(area.height) {
31            out.push('\n');
32        }
33    }
34
35    out
36}
37
38/// Paint a snapshot buffer onto the current frame
39///
40/// Clears the frame first, then copies cells from the snapshot.
41pub fn paint_snapshot(f: &mut Frame, snapshot: &Buffer) {
42    let screen = f.area();
43    f.render_widget(Clear, screen);
44
45    let snap_area = snapshot.area;
46    let x_end = screen
47        .x
48        .saturating_add(screen.width)
49        .min(snap_area.x.saturating_add(snap_area.width));
50    let y_end = screen
51        .y
52        .saturating_add(screen.height)
53        .min(snap_area.y.saturating_add(snap_area.height));
54
55    for y in screen.y..y_end {
56        for x in screen.x..x_end {
57            f.buffer_mut()[(x, y)] = snapshot[(x, y)].clone();
58        }
59    }
60}
61
62/// Dim a buffer by scaling colors towards black
63///
64/// `factor` ranges from 0.0 (no change) to 1.0 (fully dimmed/black)
65/// Handles RGB, indexed, and named colors.
66/// Emoji characters are replaced with spaces (they can't be dimmed).
67pub fn dim_buffer(buffer: &mut Buffer, factor: f32) {
68    let factor = factor.clamp(0.0, 1.0);
69    let scale = 1.0 - factor; // 0.7 factor = 0.3 scale (30% brightness)
70
71    for cell in buffer.content.iter_mut() {
72        // Replace emoji with space - they're pre-colored and can't be dimmed
73        if contains_emoji(cell.symbol()) {
74            cell.set_symbol(" ");
75        }
76        cell.fg = dim_color(cell.fg, scale);
77        cell.bg = dim_color(cell.bg, scale);
78    }
79}
80
81/// Check if a string contains emoji characters
82fn contains_emoji(s: &str) -> bool {
83    for c in s.chars() {
84        if is_emoji(c) {
85            return true;
86        }
87    }
88    false
89}
90
91/// Check if a character is a colored emoji (picture emoji that can't be styled)
92///
93/// Note: This intentionally excludes Dingbats (0x2700-0x27BF) and Miscellaneous
94/// Symbols (0x2600-0x26FF) because those include common TUI glyphs like
95/// checkmarks, arrows, and stars that can be styled normally.
96fn is_emoji(c: char) -> bool {
97    let cp = c as u32;
98    // Only match true "picture emoji" that are pre-colored
99    matches!(cp,
100        // Miscellaneous Symbols and Pictographs (🌀-🗿)
101        0x1F300..=0x1F5FF |
102        // Emoticons (😀-🙏)
103        0x1F600..=0x1F64F |
104        // Transport and Map Symbols (🚀-🛿)
105        0x1F680..=0x1F6FF |
106        // Supplemental Symbols and Pictographs (🤀-🧿)
107        0x1F900..=0x1F9FF |
108        // Symbols and Pictographs Extended-A (🩠-🩿)
109        0x1FA00..=0x1FA6F |
110        // Symbols and Pictographs Extended-B (🪀-🫿)
111        0x1FA70..=0x1FAFF |
112        // Regional Indicator Symbols for flags (🇦-🇿)
113        0x1F1E0..=0x1F1FF
114    )
115}
116
117/// Dim a single color by scaling towards black
118fn dim_color(color: ratatui::style::Color, scale: f32) -> ratatui::style::Color {
119    use ratatui::style::Color;
120
121    match color {
122        Color::Rgb(r, g, b) => Color::Rgb(
123            ((r as f32) * scale) as u8,
124            ((g as f32) * scale) as u8,
125            ((b as f32) * scale) as u8,
126        ),
127        Color::Indexed(idx) => {
128            // Convert indexed colors to RGB, dim, then back
129            // Standard 16 colors (0-15) and grayscale (232-255) are common
130            if let Some((r, g, b)) = indexed_to_rgb(idx) {
131                Color::Rgb(
132                    ((r as f32) * scale) as u8,
133                    ((g as f32) * scale) as u8,
134                    ((b as f32) * scale) as u8,
135                )
136            } else {
137                color // Keep as-is if can't convert
138            }
139        }
140        // Named colors - convert to RGB approximations
141        Color::Black => Color::Black,
142        Color::Red => dim_named_color(205, 0, 0, scale),
143        Color::Green => dim_named_color(0, 205, 0, scale),
144        Color::Yellow => dim_named_color(205, 205, 0, scale),
145        Color::Blue => dim_named_color(0, 0, 238, scale),
146        Color::Magenta => dim_named_color(205, 0, 205, scale),
147        Color::Cyan => dim_named_color(0, 205, 205, scale),
148        Color::Gray => dim_named_color(229, 229, 229, scale),
149        Color::DarkGray => dim_named_color(127, 127, 127, scale),
150        Color::LightRed => dim_named_color(255, 0, 0, scale),
151        Color::LightGreen => dim_named_color(0, 255, 0, scale),
152        Color::LightYellow => dim_named_color(255, 255, 0, scale),
153        Color::LightBlue => dim_named_color(92, 92, 255, scale),
154        Color::LightMagenta => dim_named_color(255, 0, 255, scale),
155        Color::LightCyan => dim_named_color(0, 255, 255, scale),
156        Color::White => dim_named_color(255, 255, 255, scale),
157        Color::Reset => Color::Reset,
158    }
159}
160
161fn dim_named_color(r: u8, g: u8, b: u8, scale: f32) -> ratatui::style::Color {
162    ratatui::style::Color::Rgb(
163        ((r as f32) * scale) as u8,
164        ((g as f32) * scale) as u8,
165        ((b as f32) * scale) as u8,
166    )
167}
168
169/// Convert 256-color index to RGB (approximate)
170fn indexed_to_rgb(idx: u8) -> Option<(u8, u8, u8)> {
171    match idx {
172        // Standard 16 colors
173        0 => Some((0, 0, 0)),        // Black
174        1 => Some((128, 0, 0)),      // Red
175        2 => Some((0, 128, 0)),      // Green
176        3 => Some((128, 128, 0)),    // Yellow
177        4 => Some((0, 0, 128)),      // Blue
178        5 => Some((128, 0, 128)),    // Magenta
179        6 => Some((0, 128, 128)),    // Cyan
180        7 => Some((192, 192, 192)),  // White/Gray
181        8 => Some((128, 128, 128)),  // Bright Black/Dark Gray
182        9 => Some((255, 0, 0)),      // Bright Red
183        10 => Some((0, 255, 0)),     // Bright Green
184        11 => Some((255, 255, 0)),   // Bright Yellow
185        12 => Some((0, 0, 255)),     // Bright Blue
186        13 => Some((255, 0, 255)),   // Bright Magenta
187        14 => Some((0, 255, 255)),   // Bright Cyan
188        15 => Some((255, 255, 255)), // Bright White
189        // 216 color cube (16-231)
190        16..=231 => {
191            let idx = idx - 16;
192            let r = (idx / 36) % 6;
193            let g = (idx / 6) % 6;
194            let b = idx % 6;
195            let to_rgb = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
196            Some((to_rgb(r), to_rgb(g), to_rgb(b)))
197        }
198        // Grayscale (232-255)
199        232..=255 => {
200            let gray = 8 + (idx - 232) * 10;
201            Some((gray, gray, gray))
202        }
203    }
204}
205
206/// An item in a debug banner (status bar)
207#[derive(Clone)]
208pub struct BannerItem<'a> {
209    /// The key/label shown in a highlighted style
210    pub key: &'a str,
211    /// The description shown after the key
212    pub label: &'a str,
213    /// Style for the key
214    pub key_style: Style,
215}
216
217impl<'a> BannerItem<'a> {
218    /// Create a new banner item
219    pub fn new(key: &'a str, label: &'a str, key_style: Style) -> Self {
220        Self {
221            key,
222            label,
223            key_style,
224        }
225    }
226}
227
228/// A debug banner widget (status bar at bottom)
229///
230/// # Example
231///
232/// ```ignore
233/// let banner = DebugBanner::new()
234///     .title("DEBUG")
235///     .item(BannerItem::new("F12", "resume", key_style))
236///     .item(BannerItem::new("S", "state", key_style))
237///     .background(bg_style);
238///
239/// f.render_widget(banner, area);
240/// ```
241pub struct DebugBanner<'a> {
242    title: Option<&'a str>,
243    title_style: Style,
244    items: Vec<BannerItem<'a>>,
245    label_style: Style,
246    background: Style,
247}
248
249impl<'a> Default for DebugBanner<'a> {
250    fn default() -> Self {
251        Self::new()
252    }
253}
254
255impl<'a> DebugBanner<'a> {
256    /// Create a new empty debug banner
257    pub fn new() -> Self {
258        Self {
259            title: None,
260            title_style: Style::default(),
261            items: Vec::new(),
262            label_style: Style::default(),
263            background: Style::default(),
264        }
265    }
266
267    /// Set the banner title (e.g., "DEBUG")
268    pub fn title(mut self, title: &'a str) -> Self {
269        self.title = Some(title);
270        self
271    }
272
273    /// Set the title style
274    pub fn title_style(mut self, style: Style) -> Self {
275        self.title_style = style;
276        self
277    }
278
279    /// Add an item to the banner
280    pub fn item(mut self, item: BannerItem<'a>) -> Self {
281        self.items.push(item);
282        self
283    }
284
285    /// Set the style for item labels
286    pub fn label_style(mut self, style: Style) -> Self {
287        self.label_style = style;
288        self
289    }
290
291    /// Set the background style
292    pub fn background(mut self, style: Style) -> Self {
293        self.background = style;
294        self
295    }
296}
297
298impl Widget for DebugBanner<'_> {
299    fn render(self, area: Rect, buf: &mut Buffer) {
300        if area.height == 0 || area.width == 0 {
301            return;
302        }
303
304        // Clear the entire line so the banner overrides any previous content.
305        for y in area.y..area.y.saturating_add(area.height) {
306            for x in area.x..area.x.saturating_add(area.width) {
307                if let Some(cell) = buf.cell_mut((x, y)) {
308                    cell.set_symbol(" ");
309                    cell.set_style(self.background);
310                }
311            }
312        }
313
314        let mut spans = Vec::new();
315
316        // Add title if present
317        if let Some(title) = self.title {
318            spans.push(Span::styled(format!(" {title} "), self.title_style));
319            spans.push(Span::raw(" "));
320        }
321
322        // Add items
323        for item in &self.items {
324            spans.push(Span::styled(format!(" {} ", item.key), item.key_style));
325            spans.push(Span::styled(format!(" {} ", item.label), self.label_style));
326        }
327
328        let line = Paragraph::new(Line::from(spans)).style(self.background);
329        line.render(area, buf);
330    }
331}
332
333/// Style configuration for debug table rendering
334#[derive(Clone)]
335pub struct DebugTableStyle {
336    /// Style for the header row
337    pub header: Style,
338    /// Style for section titles
339    pub section: Style,
340    /// Style for entry keys
341    pub key: Style,
342    /// Style for entry values
343    pub value: Style,
344    /// Alternating row styles (even, odd)
345    pub row_styles: (Style, Style),
346}
347
348impl Default for DebugTableStyle {
349    fn default() -> Self {
350        use super::config::DebugStyle;
351        Self {
352            header: Style::default()
353                .fg(DebugStyle::accent())
354                .bg(DebugStyle::overlay_bg_dark())
355                .add_modifier(Modifier::BOLD),
356            section: Style::default()
357                .fg(DebugStyle::neon_purple())
358                .bg(DebugStyle::overlay_bg_dark())
359                .add_modifier(Modifier::BOLD),
360            key: Style::default()
361                .fg(DebugStyle::neon_amber())
362                .add_modifier(Modifier::BOLD),
363            value: Style::default().fg(DebugStyle::text_primary()),
364            row_styles: (
365                Style::default().bg(DebugStyle::overlay_bg()),
366                Style::default().bg(DebugStyle::overlay_bg_alt()),
367            ),
368        }
369    }
370}
371
372/// A debug table widget that renders a DebugTableOverlay
373pub struct DebugTableWidget<'a> {
374    table: &'a DebugTableOverlay,
375    style: DebugTableStyle,
376    scroll_offset: usize,
377}
378
379impl<'a> DebugTableWidget<'a> {
380    /// Create a new debug table widget
381    pub fn new(table: &'a DebugTableOverlay) -> Self {
382        Self {
383            table,
384            style: DebugTableStyle::default(),
385            scroll_offset: 0,
386        }
387    }
388
389    /// Set the style configuration
390    pub fn style(mut self, style: DebugTableStyle) -> Self {
391        self.style = style;
392        self
393    }
394
395    /// Set the scroll offset for the table body
396    pub fn scroll_offset(mut self, scroll_offset: usize) -> Self {
397        self.scroll_offset = scroll_offset;
398        self
399    }
400}
401
402impl Widget for DebugTableWidget<'_> {
403    fn render(self, area: Rect, buf: &mut Buffer) {
404        if area.height < 2 || area.width < 10 {
405            return;
406        }
407
408        // Calculate column widths
409        let max_key_len = self
410            .table
411            .rows
412            .iter()
413            .filter_map(|row| match row {
414                DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
415                DebugTableRow::Section(_) => None,
416            })
417            .max()
418            .unwrap_or(0) as u16;
419
420        let max_label = area.width.saturating_sub(8).max(10);
421        let label_width = max_key_len.saturating_add(2).clamp(12, 30).min(max_label);
422        let constraints = [Constraint::Length(label_width), Constraint::Min(0)];
423
424        // Build header
425        let header = Row::new(vec![
426            Cell::from("Field").style(self.style.header),
427            Cell::from("Value").style(self.style.header),
428        ]);
429
430        // Build rows
431        let visible_rows = area.height.saturating_sub(1) as usize;
432        let max_offset = self.table.rows.len().saturating_sub(visible_rows);
433        let scroll_offset = self.scroll_offset.min(max_offset);
434
435        let rows: Vec<Row> = self
436            .table
437            .rows
438            .iter()
439            .skip(scroll_offset)
440            .enumerate()
441            .map(|(idx, row)| match row {
442                DebugTableRow::Section(title) => Row::new(vec![
443                    Cell::from(format!(" {title} ")).style(self.style.section),
444                    Cell::from(""),
445                ]),
446                DebugTableRow::Entry { key, value } => {
447                    let row_index = idx + scroll_offset;
448                    let row_style = if row_index % 2 == 0 {
449                        self.style.row_styles.0
450                    } else {
451                        self.style.row_styles.1
452                    };
453                    let syntax_style = DebugSyntaxStyle::with_base(self.style.value);
454                    let value_line = Line::from(debug_spans(value, &syntax_style));
455                    Row::new(vec![
456                        Cell::from(key.clone()).style(self.style.key),
457                        Cell::from(value_line).style(self.style.value),
458                    ])
459                    .style(row_style)
460                }
461            })
462            .collect();
463
464        let table = Table::new(rows, constraints)
465            .header(header)
466            .column_spacing(2);
467
468        table.render(area, buf);
469    }
470}
471
472/// A widget that renders a cell preview
473pub struct CellPreviewWidget<'a> {
474    preview: &'a CellPreview,
475    label_style: Style,
476    value_style: Style,
477}
478
479impl<'a> CellPreviewWidget<'a> {
480    /// Create a new cell preview widget with default neon styling
481    pub fn new(preview: &'a CellPreview) -> Self {
482        use super::config::DebugStyle;
483        Self {
484            preview,
485            label_style: Style::default().fg(DebugStyle::text_secondary()),
486            value_style: Style::default().fg(DebugStyle::text_primary()),
487        }
488    }
489
490    /// Set the style for labels (fg, bg, etc.)
491    pub fn label_style(mut self, style: Style) -> Self {
492        self.label_style = style;
493        self
494    }
495
496    /// Set the style for values
497    pub fn value_style(mut self, style: Style) -> Self {
498        self.value_style = style;
499        self
500    }
501}
502
503impl Widget for CellPreviewWidget<'_> {
504    fn render(self, area: Rect, buf: &mut Buffer) {
505        use super::config::DebugStyle;
506
507        if area.width < 20 || area.height < 1 {
508            return;
509        }
510
511        // Render the character with its actual style
512        let char_style = Style::default()
513            .fg(self.preview.fg)
514            .bg(self.preview.bg)
515            .add_modifier(self.preview.modifier);
516
517        // Format RGB values compactly
518        let fg_str = format_color_compact(self.preview.fg);
519        let bg_str = format_color_compact(self.preview.bg);
520        let mod_str = format_modifier_compact(self.preview.modifier);
521
522        // Character background highlight
523        let char_bg = Style::default().bg(DebugStyle::bg_surface());
524        let mod_style = Style::default().fg(DebugStyle::neon_purple());
525
526        // Single line: [char]  fg █ RGB  bg █ RGB  mod
527        let mut spans = vec![
528            Span::styled(" ", char_bg),
529            Span::styled(self.preview.symbol.clone(), char_style),
530            Span::styled(" ", char_bg),
531            Span::styled("  fg ", self.label_style),
532            Span::styled("█", Style::default().fg(self.preview.fg)),
533            Span::styled(format!(" {fg_str}"), self.value_style),
534            Span::styled("  bg ", self.label_style),
535            Span::styled("█", Style::default().fg(self.preview.bg)),
536            Span::styled(format!(" {bg_str}"), self.value_style),
537        ];
538
539        if !mod_str.is_empty() {
540            spans.push(Span::styled(format!("  {mod_str}"), mod_style));
541        }
542
543        let line = Paragraph::new(Line::from(spans));
544        line.render(area, buf);
545    }
546}
547
548// ============================================================================
549// Debug Value Highlighting
550// ============================================================================
551
552#[derive(Clone)]
553pub(crate) struct DebugSyntaxStyle {
554    punctuation: Style,
555    string: Style,
556    number: Style,
557    keyword: Style,
558    identifier: Style,
559    fallback: Style,
560}
561
562impl DebugSyntaxStyle {
563    pub(crate) fn with_base(base: Style) -> Self {
564        Self {
565            punctuation: Style::default().fg(DebugStyle::text_secondary()),
566            string: Style::default().fg(DebugStyle::neon_green()),
567            number: Style::default().fg(DebugStyle::neon_cyan()),
568            keyword: Style::default().fg(DebugStyle::neon_purple()),
569            identifier: base,
570            fallback: base,
571        }
572    }
573}
574
575pub(crate) fn debug_spans(value: &str, style: &DebugSyntaxStyle) -> Vec<Span<'static>> {
576    let mut spans: Vec<Span<'static>> = Vec::new();
577    let mut chars = value.chars().peekable();
578
579    #[allow(clippy::while_let_on_iterator)]
580    while let Some(ch) = chars.next() {
581        if ch == '"' || ch == '\'' {
582            let quote = ch;
583            let mut token = String::from(quote);
584            let mut escaped = false;
585            while let Some(next) = chars.next() {
586                token.push(next);
587                if escaped {
588                    escaped = false;
589                    continue;
590                }
591                if next == '\\' {
592                    escaped = true;
593                    continue;
594                }
595                if next == quote {
596                    break;
597                }
598            }
599            spans.push(Span::styled(token, style.string));
600            continue;
601        }
602
603        if is_debug_punctuation(ch) {
604            spans.push(Span::styled(ch.to_string(), style.punctuation));
605            continue;
606        }
607
608        if ch.is_whitespace() {
609            spans.push(Span::styled(ch.to_string(), style.fallback));
610            continue;
611        }
612
613        if is_number_start(ch, chars.peek()) {
614            let mut token = String::new();
615            token.push(ch);
616            while let Some(&next) = chars.peek() {
617                if is_number_char(next) {
618                    token.push(next);
619                    chars.next();
620                } else {
621                    break;
622                }
623            }
624            spans.push(Span::styled(token, style.number));
625            continue;
626        }
627
628        if is_ident_start(ch) {
629            let mut token = String::new();
630            token.push(ch);
631            while let Some(&next) = chars.peek() {
632                if is_ident_char(next) {
633                    token.push(next);
634                    chars.next();
635                } else {
636                    break;
637                }
638            }
639            let token_style = if is_debug_keyword(&token) {
640                style.keyword
641            } else {
642                style.identifier
643            };
644            spans.push(Span::styled(token, token_style));
645            continue;
646        }
647
648        spans.push(Span::styled(ch.to_string(), style.fallback));
649    }
650
651    spans
652}
653
654fn is_debug_punctuation(ch: char) -> bool {
655    matches!(ch, '{' | '}' | '[' | ']' | '(' | ')' | ':' | ',' | '=')
656}
657
658fn is_number_start(ch: char, next: Option<&char>) -> bool {
659    if ch.is_ascii_digit() {
660        return true;
661    }
662    if ch == '-' {
663        return next.map(|c| c.is_ascii_digit()).unwrap_or(false);
664    }
665    false
666}
667
668fn is_number_char(ch: char) -> bool {
669    ch.is_ascii_digit() || matches!(ch, '.' | '_' | '+' | '-' | 'e' | 'E')
670}
671
672fn is_ident_start(ch: char) -> bool {
673    ch.is_ascii_alphabetic() || ch == '_'
674}
675
676fn is_ident_char(ch: char) -> bool {
677    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
678}
679
680fn is_debug_keyword(token: &str) -> bool {
681    matches!(token, "true" | "false" | "None" | "Some" | "null")
682}
683
684// ============================================================================
685// Action Log Widget
686// ============================================================================
687
688/// Style configuration for action log rendering
689#[derive(Clone)]
690pub struct ActionLogStyle {
691    /// Style for the header row
692    pub header: Style,
693    /// Style for sequence numbers
694    pub sequence: Style,
695    /// Style for action names
696    pub name: Style,
697    /// Style for action parameters
698    pub params: Style,
699    /// Style for elapsed time
700    pub elapsed: Style,
701    /// Selected row style
702    pub selected: Style,
703    /// Alternating row styles (even, odd)
704    pub row_styles: (Style, Style),
705}
706
707impl Default for ActionLogStyle {
708    fn default() -> Self {
709        use super::config::DebugStyle;
710        Self {
711            header: Style::default()
712                .fg(DebugStyle::accent())
713                .bg(DebugStyle::overlay_bg_dark())
714                .add_modifier(Modifier::BOLD),
715            sequence: Style::default().fg(DebugStyle::text_secondary()),
716            name: Style::default()
717                .fg(DebugStyle::neon_amber())
718                .add_modifier(Modifier::BOLD),
719            params: Style::default().fg(DebugStyle::text_primary()),
720            elapsed: Style::default().fg(DebugStyle::text_secondary()),
721            selected: Style::default()
722                .bg(DebugStyle::bg_highlight())
723                .add_modifier(Modifier::BOLD),
724            row_styles: (
725                Style::default().bg(DebugStyle::overlay_bg()),
726                Style::default().bg(DebugStyle::overlay_bg_alt()),
727            ),
728        }
729    }
730}
731
732/// A widget for rendering an action log overlay
733///
734/// Displays recent actions in a scrollable table format with:
735/// - Sequence number
736/// - Action name
737/// - Parameters (truncated if necessary)
738/// - Elapsed time since action
739pub struct ActionLogWidget<'a> {
740    log: &'a ActionLogOverlay,
741    style: ActionLogStyle,
742    /// Number of visible rows (for scroll calculations)
743    visible_rows: usize,
744}
745
746impl<'a> ActionLogWidget<'a> {
747    /// Create a new action log widget
748    pub fn new(log: &'a ActionLogOverlay) -> Self {
749        Self {
750            log,
751            style: ActionLogStyle::default(),
752            visible_rows: 10, // Default, will be adjusted based on area
753        }
754    }
755
756    /// Set the style configuration
757    pub fn style(mut self, style: ActionLogStyle) -> Self {
758        self.style = style;
759        self
760    }
761
762    /// Set the number of visible rows
763    pub fn visible_rows(mut self, rows: usize) -> Self {
764        self.visible_rows = rows;
765        self
766    }
767}
768
769impl Widget for ActionLogWidget<'_> {
770    fn render(self, area: Rect, buf: &mut Buffer) {
771        if area.height < 2 || area.width < 30 {
772            return;
773        }
774
775        // Reserve 1 row for header
776        let visible_rows = (area.height.saturating_sub(1)) as usize;
777
778        // Column layout: [#] [Action] [Params] [Elapsed]
779        let constraints = [
780            Constraint::Length(5),  // Sequence #
781            Constraint::Length(20), // Action name
782            Constraint::Min(30),    // Params (flexible)
783            Constraint::Length(8),  // Elapsed
784        ];
785
786        // Header row
787        let header = Row::new(vec![
788            Cell::from("#").style(self.style.header),
789            Cell::from("Action").style(self.style.header),
790            Cell::from("Params").style(self.style.header),
791            Cell::from("Elapsed").style(self.style.header),
792        ]);
793
794        // Calculate scroll offset to keep selected row visible
795        let scroll_offset = self.log.scroll_offset_for(visible_rows);
796
797        // Build visible rows
798        let rows: Vec<Row> = self
799            .log
800            .entries
801            .iter()
802            .enumerate()
803            .skip(scroll_offset)
804            .take(visible_rows)
805            .map(|(idx, entry)| {
806                let is_selected = idx == self.log.selected;
807                let base_style = if is_selected {
808                    self.style.selected
809                } else if idx % 2 == 0 {
810                    self.style.row_styles.0
811                } else {
812                    self.style.row_styles.1
813                };
814
815                let params_compact = entry.params.replace('\n', " ");
816                let params_compact = params_compact
817                    .split_whitespace()
818                    .collect::<Vec<_>>()
819                    .join(" ");
820
821                // Truncate params if needed (char-aware to avoid UTF-8 panic)
822                let params = if params_compact.chars().count() > 60 {
823                    let truncated: String = params_compact.chars().take(57).collect();
824                    format!("{}...", truncated)
825                } else {
826                    params_compact
827                };
828                let syntax_style = DebugSyntaxStyle::with_base(self.style.params);
829                let params_line = Line::from(debug_spans(&params, &syntax_style));
830
831                Row::new(vec![
832                    Cell::from(format!("{}", entry.sequence)).style(self.style.sequence),
833                    Cell::from(entry.name.clone()).style(self.style.name),
834                    Cell::from(Text::from(params_line)).style(self.style.params),
835                    Cell::from(entry.elapsed.clone()).style(self.style.elapsed),
836                ])
837                .style(base_style)
838            })
839            .collect();
840
841        let table = Table::new(rows, constraints)
842            .header(header)
843            .column_spacing(1);
844
845        table.render(area, buf);
846    }
847}
848
849#[cfg(test)]
850mod tests {
851    use super::*;
852    use ratatui::layout::Rect;
853
854    #[test]
855    fn test_buffer_to_text() {
856        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
857
858        // Write some text
859        buffer[(0, 0)].set_char('H');
860        buffer[(1, 0)].set_char('i');
861        buffer[(0, 1)].set_char('!');
862
863        let text = buffer_to_text(&buffer);
864        let lines: Vec<&str> = text.lines().collect();
865
866        assert_eq!(lines[0], "Hi");
867        assert_eq!(lines[1], "!");
868    }
869
870    #[test]
871    fn test_debug_banner() {
872        let banner =
873            DebugBanner::new()
874                .title("TEST")
875                .item(BannerItem::new("F1", "help", Style::default()));
876
877        let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 1));
878        banner.render(Rect::new(0, 0, 40, 1), &mut buffer);
879
880        let text = buffer_to_text(&buffer);
881        assert!(text.contains("TEST"));
882        assert!(text.contains("F1"));
883        assert!(text.contains("help"));
884    }
885}