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 DebugTableStyle {
349    /// Create a table style from a `DebugStyle` color palette.
350    pub fn from_style(s: &super::config::DebugStyle) -> Self {
351        Self {
352            header: Style::default()
353                .fg(s.accent)
354                .bg(s.overlay_bg_dark)
355                .add_modifier(Modifier::BOLD),
356            section: Style::default()
357                .fg(s.neon_purple)
358                .bg(s.overlay_bg_dark)
359                .add_modifier(Modifier::BOLD),
360            key: Style::default()
361                .fg(s.neon_amber)
362                .add_modifier(Modifier::BOLD),
363            value: Style::default().fg(s.text_primary),
364            row_styles: (
365                Style::default().bg(s.overlay_bg),
366                Style::default().bg(s.overlay_bg_alt),
367            ),
368        }
369    }
370}
371
372#[allow(deprecated)]
373impl Default for DebugTableStyle {
374    fn default() -> Self {
375        Self::from_style(&super::config::DebugStyle::default())
376    }
377}
378
379/// A debug table widget that renders a DebugTableOverlay
380pub struct DebugTableWidget<'a> {
381    table: &'a DebugTableOverlay,
382    style: DebugTableStyle,
383    scroll_offset: usize,
384}
385
386impl<'a> DebugTableWidget<'a> {
387    /// Create a new debug table widget
388    pub fn new(table: &'a DebugTableOverlay) -> Self {
389        Self {
390            table,
391            style: DebugTableStyle::default(),
392            scroll_offset: 0,
393        }
394    }
395
396    /// Set the style configuration
397    pub fn style(mut self, style: DebugTableStyle) -> Self {
398        self.style = style;
399        self
400    }
401
402    /// Set the scroll offset for the table body
403    pub fn scroll_offset(mut self, scroll_offset: usize) -> Self {
404        self.scroll_offset = scroll_offset;
405        self
406    }
407}
408
409impl Widget for DebugTableWidget<'_> {
410    fn render(self, area: Rect, buf: &mut Buffer) {
411        if area.height < 2 || area.width < 10 {
412            return;
413        }
414
415        // Calculate column widths
416        let max_key_len = self
417            .table
418            .rows
419            .iter()
420            .filter_map(|row| match row {
421                DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
422                DebugTableRow::Section(_) => None,
423            })
424            .max()
425            .unwrap_or(0) as u16;
426
427        let max_label = area.width.saturating_sub(8).max(10);
428        let label_width = max_key_len.saturating_add(2).clamp(12, 30).min(max_label);
429        let constraints = [Constraint::Length(label_width), Constraint::Min(0)];
430
431        // Build header
432        let header = Row::new(vec![
433            Cell::from("Field").style(self.style.header),
434            Cell::from("Value").style(self.style.header),
435        ]);
436
437        // Build rows
438        let visible_rows = area.height.saturating_sub(1) as usize;
439        let max_offset = self.table.rows.len().saturating_sub(visible_rows);
440        let scroll_offset = self.scroll_offset.min(max_offset);
441
442        let rows: Vec<Row> = self
443            .table
444            .rows
445            .iter()
446            .skip(scroll_offset)
447            .enumerate()
448            .map(|(idx, row)| match row {
449                DebugTableRow::Section(title) => Row::new(vec![
450                    Cell::from(format!(" {title} ")).style(self.style.section),
451                    Cell::from(""),
452                ]),
453                DebugTableRow::Entry { key, value } => {
454                    let row_index = idx + scroll_offset;
455                    let row_style = if row_index % 2 == 0 {
456                        self.style.row_styles.0
457                    } else {
458                        self.style.row_styles.1
459                    };
460                    let syntax_style = DebugSyntaxStyle::with_base(self.style.value);
461                    let value_line = Line::from(debug_spans(value, &syntax_style));
462                    Row::new(vec![
463                        Cell::from(key.clone()).style(self.style.key),
464                        Cell::from(value_line).style(self.style.value),
465                    ])
466                    .style(row_style)
467                }
468            })
469            .collect();
470
471        let table = Table::new(rows, constraints)
472            .header(header)
473            .column_spacing(2);
474
475        table.render(area, buf);
476    }
477}
478
479/// A widget that renders a cell preview
480pub struct CellPreviewWidget<'a> {
481    preview: &'a CellPreview,
482    label_style: Style,
483    value_style: Style,
484    bg_surface: ratatui::style::Color,
485    mod_color: ratatui::style::Color,
486}
487
488impl<'a> CellPreviewWidget<'a> {
489    /// Create a cell preview widget styled from a `DebugStyle` palette.
490    pub fn from_style(preview: &'a CellPreview, s: &DebugStyle) -> Self {
491        Self {
492            preview,
493            label_style: Style::default().fg(s.text_secondary),
494            value_style: Style::default().fg(s.text_primary),
495            bg_surface: s.bg_surface,
496            mod_color: s.neon_purple,
497        }
498    }
499
500    /// Create a new cell preview widget with default neon styling
501    #[allow(deprecated)]
502    pub fn new(preview: &'a CellPreview) -> Self {
503        Self::from_style(preview, &DebugStyle::default())
504    }
505
506    /// Set the style for labels (fg, bg, etc.)
507    pub fn label_style(mut self, style: Style) -> Self {
508        self.label_style = style;
509        self
510    }
511
512    /// Set the style for values
513    pub fn value_style(mut self, style: Style) -> Self {
514        self.value_style = style;
515        self
516    }
517}
518
519impl Widget for CellPreviewWidget<'_> {
520    fn render(self, area: Rect, buf: &mut Buffer) {
521        if area.width < 20 || area.height < 1 {
522            return;
523        }
524
525        // Render the character with its actual style
526        let char_style = Style::default()
527            .fg(self.preview.fg)
528            .bg(self.preview.bg)
529            .add_modifier(self.preview.modifier);
530
531        // Format RGB values compactly
532        let fg_str = format_color_compact(self.preview.fg);
533        let bg_str = format_color_compact(self.preview.bg);
534        let mod_str = format_modifier_compact(self.preview.modifier);
535
536        // Character background highlight
537        let char_bg = Style::default().bg(self.bg_surface);
538        let mod_style = Style::default().fg(self.mod_color);
539
540        // Single line: [char]  fg █ RGB  bg █ RGB  mod
541        let mut spans = vec![
542            Span::styled(" ", char_bg),
543            Span::styled(self.preview.symbol.clone(), char_style),
544            Span::styled(" ", char_bg),
545            Span::styled("  fg ", self.label_style),
546            Span::styled("█", Style::default().fg(self.preview.fg)),
547            Span::styled(format!(" {fg_str}"), self.value_style),
548            Span::styled("  bg ", self.label_style),
549            Span::styled("█", Style::default().fg(self.preview.bg)),
550            Span::styled(format!(" {bg_str}"), self.value_style),
551        ];
552
553        if !mod_str.is_empty() {
554            spans.push(Span::styled(format!("  {mod_str}"), mod_style));
555        }
556
557        let line = Paragraph::new(Line::from(spans));
558        line.render(area, buf);
559    }
560}
561
562// ============================================================================
563// Debug Value Highlighting
564// ============================================================================
565
566#[derive(Clone)]
567pub(crate) struct DebugSyntaxStyle {
568    punctuation: Style,
569    string: Style,
570    number: Style,
571    keyword: Style,
572    identifier: Style,
573    fallback: Style,
574}
575
576impl DebugSyntaxStyle {
577    /// Create syntax style from a `DebugStyle` palette and a base text style.
578    pub(crate) fn from_style(s: &DebugStyle, base: Style) -> Self {
579        Self {
580            punctuation: Style::default().fg(s.text_secondary),
581            string: Style::default().fg(s.neon_green),
582            number: Style::default().fg(s.neon_cyan),
583            keyword: Style::default().fg(s.neon_purple),
584            identifier: base,
585            fallback: base,
586        }
587    }
588
589    #[allow(deprecated)]
590    pub(crate) fn with_base(base: Style) -> Self {
591        Self::from_style(&DebugStyle::default(), base)
592    }
593}
594
595pub(crate) fn debug_spans(value: &str, style: &DebugSyntaxStyle) -> Vec<Span<'static>> {
596    let mut spans: Vec<Span<'static>> = Vec::new();
597    let mut chars = value.chars().peekable();
598
599    #[allow(clippy::while_let_on_iterator)]
600    while let Some(ch) = chars.next() {
601        if ch == '"' || ch == '\'' {
602            let quote = ch;
603            let mut token = String::from(quote);
604            let mut escaped = false;
605            while let Some(next) = chars.next() {
606                token.push(next);
607                if escaped {
608                    escaped = false;
609                    continue;
610                }
611                if next == '\\' {
612                    escaped = true;
613                    continue;
614                }
615                if next == quote {
616                    break;
617                }
618            }
619            spans.push(Span::styled(token, style.string));
620            continue;
621        }
622
623        if is_debug_punctuation(ch) {
624            spans.push(Span::styled(ch.to_string(), style.punctuation));
625            continue;
626        }
627
628        if ch.is_whitespace() {
629            spans.push(Span::styled(ch.to_string(), style.fallback));
630            continue;
631        }
632
633        if is_number_start(ch, chars.peek()) {
634            let mut token = String::new();
635            token.push(ch);
636            while let Some(&next) = chars.peek() {
637                if is_number_char(next) {
638                    token.push(next);
639                    chars.next();
640                } else {
641                    break;
642                }
643            }
644            spans.push(Span::styled(token, style.number));
645            continue;
646        }
647
648        if is_ident_start(ch) {
649            let mut token = String::new();
650            token.push(ch);
651            while let Some(&next) = chars.peek() {
652                if is_ident_char(next) {
653                    token.push(next);
654                    chars.next();
655                } else {
656                    break;
657                }
658            }
659            let token_style = if is_debug_keyword(&token) {
660                style.keyword
661            } else {
662                style.identifier
663            };
664            spans.push(Span::styled(token, token_style));
665            continue;
666        }
667
668        spans.push(Span::styled(ch.to_string(), style.fallback));
669    }
670
671    spans
672}
673
674fn is_debug_punctuation(ch: char) -> bool {
675    matches!(ch, '{' | '}' | '[' | ']' | '(' | ')' | ':' | ',' | '=')
676}
677
678fn is_number_start(ch: char, next: Option<&char>) -> bool {
679    if ch.is_ascii_digit() {
680        return true;
681    }
682    if ch == '-' {
683        return next.map(|c| c.is_ascii_digit()).unwrap_or(false);
684    }
685    false
686}
687
688fn is_number_char(ch: char) -> bool {
689    ch.is_ascii_digit() || matches!(ch, '.' | '_' | '+' | '-' | 'e' | 'E')
690}
691
692fn is_ident_start(ch: char) -> bool {
693    ch.is_ascii_alphabetic() || ch == '_'
694}
695
696fn is_ident_char(ch: char) -> bool {
697    ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
698}
699
700fn is_debug_keyword(token: &str) -> bool {
701    matches!(token, "true" | "false" | "None" | "Some" | "null")
702}
703
704// ============================================================================
705// Action Log Widget
706// ============================================================================
707
708/// Style configuration for action log rendering
709#[derive(Clone)]
710pub struct ActionLogStyle {
711    /// Style for the header row
712    pub header: Style,
713    /// Style for sequence numbers
714    pub sequence: Style,
715    /// Style for action names
716    pub name: Style,
717    /// Style for action parameters
718    pub params: Style,
719    /// Style for elapsed time
720    pub elapsed: Style,
721    /// Selected row style
722    pub selected: Style,
723    /// Alternating row styles (even, odd)
724    pub row_styles: (Style, Style),
725}
726
727impl ActionLogStyle {
728    /// Create an action log style from a `DebugStyle` color palette.
729    pub fn from_style(s: &super::config::DebugStyle) -> Self {
730        Self {
731            header: Style::default()
732                .fg(s.accent)
733                .bg(s.overlay_bg_dark)
734                .add_modifier(Modifier::BOLD),
735            sequence: Style::default().fg(s.text_secondary),
736            name: Style::default()
737                .fg(s.neon_amber)
738                .add_modifier(Modifier::BOLD),
739            params: Style::default().fg(s.text_primary),
740            elapsed: Style::default().fg(s.text_secondary),
741            selected: Style::default()
742                .bg(s.bg_highlight)
743                .add_modifier(Modifier::BOLD),
744            row_styles: (
745                Style::default().bg(s.overlay_bg),
746                Style::default().bg(s.overlay_bg_alt),
747            ),
748        }
749    }
750}
751
752#[allow(deprecated)]
753impl Default for ActionLogStyle {
754    fn default() -> Self {
755        Self::from_style(&super::config::DebugStyle::default())
756    }
757}
758
759/// A widget for rendering an action log overlay
760///
761/// Displays recent actions in a scrollable table format with:
762/// - Sequence number
763/// - Action name
764/// - Parameters (truncated if necessary)
765/// - Elapsed time since action
766pub struct ActionLogWidget<'a> {
767    log: &'a ActionLogOverlay,
768    style: ActionLogStyle,
769    /// Number of visible rows (for scroll calculations)
770    visible_rows: usize,
771}
772
773impl<'a> ActionLogWidget<'a> {
774    /// Create a new action log widget
775    pub fn new(log: &'a ActionLogOverlay) -> Self {
776        Self {
777            log,
778            style: ActionLogStyle::default(),
779            visible_rows: 10, // Default, will be adjusted based on area
780        }
781    }
782
783    /// Set the style configuration
784    pub fn style(mut self, style: ActionLogStyle) -> Self {
785        self.style = style;
786        self
787    }
788
789    /// Set the number of visible rows
790    pub fn visible_rows(mut self, rows: usize) -> Self {
791        self.visible_rows = rows;
792        self
793    }
794}
795
796impl Widget for ActionLogWidget<'_> {
797    fn render(self, area: Rect, buf: &mut Buffer) {
798        if area.height < 2 || area.width < 30 {
799            return;
800        }
801
802        // Reserve 1 row for header
803        let visible_rows = (area.height.saturating_sub(1)) as usize;
804
805        // Column layout: [#] [Action] [Params] [Elapsed]
806        let constraints = [
807            Constraint::Length(5),  // Sequence #
808            Constraint::Length(20), // Action name
809            Constraint::Min(30),    // Params (flexible)
810            Constraint::Length(8),  // Elapsed
811        ];
812
813        // Header row
814        let header = Row::new(vec![
815            Cell::from("#").style(self.style.header),
816            Cell::from("Action").style(self.style.header),
817            Cell::from("Params").style(self.style.header),
818            Cell::from("Elapsed").style(self.style.header),
819        ]);
820
821        // Calculate scroll offset to keep selected row visible
822        let scroll_offset = self.log.scroll_offset_for(visible_rows);
823
824        // Build visible rows
825        let rows: Vec<Row> = self
826            .log
827            .entries
828            .iter()
829            .enumerate()
830            .skip(scroll_offset)
831            .take(visible_rows)
832            .map(|(idx, entry)| {
833                let is_selected = idx == self.log.selected;
834                let base_style = if is_selected {
835                    self.style.selected
836                } else if idx % 2 == 0 {
837                    self.style.row_styles.0
838                } else {
839                    self.style.row_styles.1
840                };
841
842                let params_compact = entry.params.replace('\n', " ");
843                let params_compact = params_compact
844                    .split_whitespace()
845                    .collect::<Vec<_>>()
846                    .join(" ");
847
848                // Truncate params if needed (char-aware to avoid UTF-8 panic)
849                let params = if params_compact.chars().count() > 60 {
850                    let truncated: String = params_compact.chars().take(57).collect();
851                    format!("{}...", truncated)
852                } else {
853                    params_compact
854                };
855                let syntax_style = DebugSyntaxStyle::with_base(self.style.params);
856                let params_line = Line::from(debug_spans(&params, &syntax_style));
857
858                Row::new(vec![
859                    Cell::from(format!("{}", entry.sequence)).style(self.style.sequence),
860                    Cell::from(entry.name.clone()).style(self.style.name),
861                    Cell::from(Text::from(params_line)).style(self.style.params),
862                    Cell::from(entry.elapsed.clone()).style(self.style.elapsed),
863                ])
864                .style(base_style)
865            })
866            .collect();
867
868        let table = Table::new(rows, constraints)
869            .header(header)
870            .column_spacing(1);
871
872        table.render(area, buf);
873    }
874}
875
876#[cfg(test)]
877mod tests {
878    use super::*;
879    use ratatui::layout::Rect;
880
881    #[test]
882    fn test_buffer_to_text() {
883        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
884
885        // Write some text
886        buffer[(0, 0)].set_char('H');
887        buffer[(1, 0)].set_char('i');
888        buffer[(0, 1)].set_char('!');
889
890        let text = buffer_to_text(&buffer);
891        let lines: Vec<&str> = text.lines().collect();
892
893        assert_eq!(lines[0], "Hi");
894        assert_eq!(lines[1], "!");
895    }
896
897    #[test]
898    fn test_debug_banner() {
899        let banner =
900            DebugBanner::new()
901                .title("TEST")
902                .item(BannerItem::new("F1", "help", Style::default()));
903
904        let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 1));
905        banner.render(Rect::new(0, 0, 40, 1), &mut buffer);
906
907        let text = buffer_to_text(&buffer);
908        assert!(text.contains("TEST"));
909        assert!(text.contains("F1"));
910        assert!(text.contains("help"));
911    }
912}