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