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::{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        // Clear the entire line so the banner overrides any previous content.
304        for y in area.y..area.y.saturating_add(area.height) {
305            for x in area.x..area.x.saturating_add(area.width) {
306                if let Some(cell) = buf.cell_mut((x, y)) {
307                    cell.set_symbol(" ");
308                    cell.set_style(self.background);
309                }
310            }
311        }
312
313        let mut spans = Vec::new();
314
315        // Add title if present
316        if let Some(title) = self.title {
317            spans.push(Span::styled(format!(" {title} "), self.title_style));
318            spans.push(Span::raw(" "));
319        }
320
321        // Add items
322        for item in &self.items {
323            spans.push(Span::styled(format!(" {} ", item.key), item.key_style));
324            spans.push(Span::styled(format!(" {} ", item.label), self.label_style));
325        }
326
327        let line = Paragraph::new(Line::from(spans)).style(self.background);
328        line.render(area, buf);
329    }
330}
331
332/// Style configuration for debug table rendering
333#[derive(Clone)]
334pub struct DebugTableStyle {
335    /// Style for the header row
336    pub header: Style,
337    /// Style for section titles
338    pub section: Style,
339    /// Style for entry keys
340    pub key: Style,
341    /// Style for entry values
342    pub value: Style,
343    /// Alternating row styles (even, odd)
344    pub row_styles: (Style, Style),
345}
346
347impl Default for DebugTableStyle {
348    fn default() -> Self {
349        use super::config::DebugStyle;
350        Self {
351            header: Style::default()
352                .fg(DebugStyle::neon_cyan())
353                .add_modifier(Modifier::BOLD),
354            section: Style::default()
355                .fg(DebugStyle::neon_purple())
356                .add_modifier(Modifier::BOLD),
357            key: Style::default()
358                .fg(DebugStyle::neon_amber())
359                .add_modifier(Modifier::BOLD),
360            value: Style::default().fg(DebugStyle::text_primary()),
361            row_styles: (
362                Style::default().bg(DebugStyle::bg_panel()),
363                Style::default().bg(DebugStyle::bg_surface()),
364            ),
365        }
366    }
367}
368
369/// A debug table widget that renders a DebugTableOverlay
370pub struct DebugTableWidget<'a> {
371    table: &'a DebugTableOverlay,
372    style: DebugTableStyle,
373    scroll_offset: usize,
374}
375
376impl<'a> DebugTableWidget<'a> {
377    /// Create a new debug table widget
378    pub fn new(table: &'a DebugTableOverlay) -> Self {
379        Self {
380            table,
381            style: DebugTableStyle::default(),
382            scroll_offset: 0,
383        }
384    }
385
386    /// Set the style configuration
387    pub fn style(mut self, style: DebugTableStyle) -> Self {
388        self.style = style;
389        self
390    }
391
392    /// Set the scroll offset for the table body
393    pub fn scroll_offset(mut self, scroll_offset: usize) -> Self {
394        self.scroll_offset = scroll_offset;
395        self
396    }
397}
398
399impl Widget for DebugTableWidget<'_> {
400    fn render(self, area: Rect, buf: &mut Buffer) {
401        if area.height < 2 || area.width < 10 {
402            return;
403        }
404
405        // Calculate column widths
406        let max_key_len = self
407            .table
408            .rows
409            .iter()
410            .filter_map(|row| match row {
411                DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
412                DebugTableRow::Section(_) => None,
413            })
414            .max()
415            .unwrap_or(0) as u16;
416
417        let max_label = area.width.saturating_sub(8).max(10);
418        let label_width = max_key_len.saturating_add(2).clamp(12, 30).min(max_label);
419        let constraints = [Constraint::Length(label_width), Constraint::Min(0)];
420
421        // Build header
422        let header = Row::new(vec![
423            Cell::from("Field").style(self.style.header),
424            Cell::from("Value").style(self.style.header),
425        ]);
426
427        // Build rows
428        let visible_rows = area.height.saturating_sub(1) as usize;
429        let max_offset = self.table.rows.len().saturating_sub(visible_rows);
430        let scroll_offset = self.scroll_offset.min(max_offset);
431
432        let rows: Vec<Row> = self
433            .table
434            .rows
435            .iter()
436            .skip(scroll_offset)
437            .enumerate()
438            .map(|(idx, row)| match row {
439                DebugTableRow::Section(title) => Row::new(vec![
440                    Cell::from(format!(" {title} ")).style(self.style.section),
441                    Cell::from(""),
442                ]),
443                DebugTableRow::Entry { key, value } => {
444                    let row_index = idx + scroll_offset;
445                    let row_style = if row_index % 2 == 0 {
446                        self.style.row_styles.0
447                    } else {
448                        self.style.row_styles.1
449                    };
450                    Row::new(vec![
451                        Cell::from(key.clone()).style(self.style.key),
452                        Cell::from(value.clone()).style(self.style.value),
453                    ])
454                    .style(row_style)
455                }
456            })
457            .collect();
458
459        let table = Table::new(rows, constraints)
460            .header(header)
461            .column_spacing(2);
462
463        table.render(area, buf);
464    }
465}
466
467/// A widget that renders a cell preview
468pub struct CellPreviewWidget<'a> {
469    preview: &'a CellPreview,
470    label_style: Style,
471    value_style: Style,
472}
473
474impl<'a> CellPreviewWidget<'a> {
475    /// Create a new cell preview widget with default neon styling
476    pub fn new(preview: &'a CellPreview) -> Self {
477        use super::config::DebugStyle;
478        Self {
479            preview,
480            label_style: Style::default().fg(DebugStyle::text_secondary()),
481            value_style: Style::default().fg(DebugStyle::text_primary()),
482        }
483    }
484
485    /// Set the style for labels (fg, bg, etc.)
486    pub fn label_style(mut self, style: Style) -> Self {
487        self.label_style = style;
488        self
489    }
490
491    /// Set the style for values
492    pub fn value_style(mut self, style: Style) -> Self {
493        self.value_style = style;
494        self
495    }
496}
497
498impl Widget for CellPreviewWidget<'_> {
499    fn render(self, area: Rect, buf: &mut Buffer) {
500        use super::config::DebugStyle;
501
502        if area.width < 20 || area.height < 1 {
503            return;
504        }
505
506        // Render the character with its actual style
507        let char_style = Style::default()
508            .fg(self.preview.fg)
509            .bg(self.preview.bg)
510            .add_modifier(self.preview.modifier);
511
512        // Format RGB values compactly
513        let fg_str = format_color_compact(self.preview.fg);
514        let bg_str = format_color_compact(self.preview.bg);
515        let mod_str = format_modifier_compact(self.preview.modifier);
516
517        // Character background highlight
518        let char_bg = Style::default().bg(DebugStyle::bg_surface());
519        let mod_style = Style::default().fg(DebugStyle::neon_purple());
520
521        // Single line: [char]  fg █ RGB  bg █ RGB  mod
522        let mut spans = vec![
523            Span::styled(" ", char_bg),
524            Span::styled(self.preview.symbol.clone(), char_style),
525            Span::styled(" ", char_bg),
526            Span::styled("  fg ", self.label_style),
527            Span::styled("█", Style::default().fg(self.preview.fg)),
528            Span::styled(format!(" {fg_str}"), self.value_style),
529            Span::styled("  bg ", self.label_style),
530            Span::styled("█", Style::default().fg(self.preview.bg)),
531            Span::styled(format!(" {bg_str}"), self.value_style),
532        ];
533
534        if !mod_str.is_empty() {
535            spans.push(Span::styled(format!("  {mod_str}"), mod_style));
536        }
537
538        let line = Paragraph::new(Line::from(spans));
539        line.render(area, buf);
540    }
541}
542
543// ============================================================================
544// Action Log Widget
545// ============================================================================
546
547/// Style configuration for action log rendering
548#[derive(Clone)]
549pub struct ActionLogStyle {
550    /// Style for the header row
551    pub header: Style,
552    /// Style for sequence numbers
553    pub sequence: Style,
554    /// Style for action names
555    pub name: Style,
556    /// Style for action parameters
557    pub params: Style,
558    /// Style for elapsed time
559    pub elapsed: Style,
560    /// Selected row style
561    pub selected: Style,
562    /// Alternating row styles (even, odd)
563    pub row_styles: (Style, Style),
564}
565
566impl Default for ActionLogStyle {
567    fn default() -> Self {
568        use super::config::DebugStyle;
569        Self {
570            header: Style::default()
571                .fg(DebugStyle::neon_cyan())
572                .add_modifier(Modifier::BOLD),
573            sequence: Style::default().fg(DebugStyle::text_secondary()),
574            name: Style::default()
575                .fg(DebugStyle::neon_amber())
576                .add_modifier(Modifier::BOLD),
577            params: Style::default().fg(DebugStyle::text_primary()),
578            elapsed: Style::default().fg(DebugStyle::text_secondary()),
579            selected: Style::default()
580                .bg(DebugStyle::bg_highlight())
581                .add_modifier(Modifier::BOLD),
582            row_styles: (
583                Style::default().bg(DebugStyle::bg_panel()),
584                Style::default().bg(DebugStyle::bg_surface()),
585            ),
586        }
587    }
588}
589
590/// A widget for rendering an action log overlay
591///
592/// Displays recent actions in a scrollable table format with:
593/// - Sequence number
594/// - Action name
595/// - Parameters (truncated if necessary)
596/// - Elapsed time since action
597pub struct ActionLogWidget<'a> {
598    log: &'a ActionLogOverlay,
599    style: ActionLogStyle,
600    /// Number of visible rows (for scroll calculations)
601    visible_rows: usize,
602}
603
604impl<'a> ActionLogWidget<'a> {
605    /// Create a new action log widget
606    pub fn new(log: &'a ActionLogOverlay) -> Self {
607        Self {
608            log,
609            style: ActionLogStyle::default(),
610            visible_rows: 10, // Default, will be adjusted based on area
611        }
612    }
613
614    /// Set the style configuration
615    pub fn style(mut self, style: ActionLogStyle) -> Self {
616        self.style = style;
617        self
618    }
619
620    /// Set the number of visible rows
621    pub fn visible_rows(mut self, rows: usize) -> Self {
622        self.visible_rows = rows;
623        self
624    }
625}
626
627impl Widget for ActionLogWidget<'_> {
628    fn render(self, area: Rect, buf: &mut Buffer) {
629        if area.height < 2 || area.width < 30 {
630            return;
631        }
632
633        // Reserve 1 row for header
634        let visible_rows = (area.height.saturating_sub(1)) as usize;
635
636        // Column layout: [#] [Action] [Params] [Elapsed]
637        let constraints = [
638            Constraint::Length(5),  // Sequence #
639            Constraint::Length(20), // Action name
640            Constraint::Min(30),    // Params (flexible)
641            Constraint::Length(8),  // Elapsed
642        ];
643
644        // Header row
645        let header = Row::new(vec![
646            Cell::from("#").style(self.style.header),
647            Cell::from("Action").style(self.style.header),
648            Cell::from("Params").style(self.style.header),
649            Cell::from("Elapsed").style(self.style.header),
650        ]);
651
652        // Calculate scroll offset to keep selected row visible
653        let scroll_offset = self.log.scroll_offset_for(visible_rows);
654
655        // Build visible rows
656        let rows: Vec<Row> = self
657            .log
658            .entries
659            .iter()
660            .enumerate()
661            .skip(scroll_offset)
662            .take(visible_rows)
663            .map(|(idx, entry)| {
664                let is_selected = idx == self.log.selected;
665                let base_style = if is_selected {
666                    self.style.selected
667                } else if idx % 2 == 0 {
668                    self.style.row_styles.0
669                } else {
670                    self.style.row_styles.1
671                };
672
673                // Truncate params if needed (char-aware to avoid UTF-8 panic)
674                let params = if entry.params.chars().count() > 60 {
675                    let truncated: String = entry.params.chars().take(57).collect();
676                    format!("{}...", truncated)
677                } else {
678                    entry.params.clone()
679                };
680
681                Row::new(vec![
682                    Cell::from(format!("{}", entry.sequence)).style(self.style.sequence),
683                    Cell::from(entry.name.clone()).style(self.style.name),
684                    Cell::from(params).style(self.style.params),
685                    Cell::from(entry.elapsed.clone()).style(self.style.elapsed),
686                ])
687                .style(base_style)
688            })
689            .collect();
690
691        let table = Table::new(rows, constraints)
692            .header(header)
693            .column_spacing(1);
694
695        table.render(area, buf);
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use ratatui::layout::Rect;
703
704    #[test]
705    fn test_buffer_to_text() {
706        let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
707
708        // Write some text
709        buffer[(0, 0)].set_char('H');
710        buffer[(1, 0)].set_char('i');
711        buffer[(0, 1)].set_char('!');
712
713        let text = buffer_to_text(&buffer);
714        let lines: Vec<&str> = text.lines().collect();
715
716        assert_eq!(lines[0], "Hi");
717        assert_eq!(lines[1], "!");
718    }
719
720    #[test]
721    fn test_debug_banner() {
722        let banner =
723            DebugBanner::new()
724                .title("TEST")
725                .item(BannerItem::new("F1", "help", Style::default()));
726
727        let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 1));
728        banner.render(Rect::new(0, 0, 40, 1), &mut buffer);
729
730        let text = buffer_to_text(&buffer);
731        assert!(text.contains("TEST"));
732        assert!(text.contains("F1"));
733        assert!(text.contains("help"));
734    }
735}