1use 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
17pub 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
38pub 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
62pub fn dim_buffer(buffer: &mut Buffer, factor: f32) {
68 let factor = factor.clamp(0.0, 1.0);
69 let scale = 1.0 - factor; for cell in buffer.content.iter_mut() {
72 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
81fn 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
91fn is_emoji(c: char) -> bool {
97 let cp = c as u32;
98 matches!(cp,
100 0x1F300..=0x1F5FF |
102 0x1F600..=0x1F64F |
104 0x1F680..=0x1F6FF |
106 0x1F900..=0x1F9FF |
108 0x1FA00..=0x1FA6F |
110 0x1FA70..=0x1FAFF |
112 0x1F1E0..=0x1F1FF
114 )
115}
116
117fn 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 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 }
139 }
140 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
169fn indexed_to_rgb(idx: u8) -> Option<(u8, u8, u8)> {
171 match idx {
172 0 => Some((0, 0, 0)), 1 => Some((128, 0, 0)), 2 => Some((0, 128, 0)), 3 => Some((128, 128, 0)), 4 => Some((0, 0, 128)), 5 => Some((128, 0, 128)), 6 => Some((0, 128, 128)), 7 => Some((192, 192, 192)), 8 => Some((128, 128, 128)), 9 => Some((255, 0, 0)), 10 => Some((0, 255, 0)), 11 => Some((255, 255, 0)), 12 => Some((0, 0, 255)), 13 => Some((255, 0, 255)), 14 => Some((0, 255, 255)), 15 => Some((255, 255, 255)), 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 232..=255 => {
200 let gray = 8 + (idx - 232) * 10;
201 Some((gray, gray, gray))
202 }
203 }
204}
205
206#[derive(Clone)]
208pub struct BannerItem<'a> {
209 pub key: &'a str,
211 pub label: &'a str,
213 pub key_style: Style,
215}
216
217impl<'a> BannerItem<'a> {
218 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
228pub 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 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 pub fn title(mut self, title: &'a str) -> Self {
269 self.title = Some(title);
270 self
271 }
272
273 pub fn title_style(mut self, style: Style) -> Self {
275 self.title_style = style;
276 self
277 }
278
279 pub fn item(mut self, item: BannerItem<'a>) -> Self {
281 self.items.push(item);
282 self
283 }
284
285 pub fn label_style(mut self, style: Style) -> Self {
287 self.label_style = style;
288 self
289 }
290
291 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 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 if let Some(title) = self.title {
318 spans.push(Span::styled(format!(" {title} "), self.title_style));
319 spans.push(Span::raw(" "));
320 }
321
322 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#[derive(Clone)]
335pub struct DebugTableStyle {
336 pub header: Style,
338 pub section: Style,
340 pub key: Style,
342 pub value: Style,
344 pub row_styles: (Style, Style),
346}
347
348impl DebugTableStyle {
349 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
379pub struct DebugTableWidget<'a> {
381 table: &'a DebugTableOverlay,
382 style: DebugTableStyle,
383 scroll_offset: usize,
384}
385
386impl<'a> DebugTableWidget<'a> {
387 pub fn new(table: &'a DebugTableOverlay) -> Self {
389 Self {
390 table,
391 style: DebugTableStyle::default(),
392 scroll_offset: 0,
393 }
394 }
395
396 pub fn style(mut self, style: DebugTableStyle) -> Self {
398 self.style = style;
399 self
400 }
401
402 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 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 let header = Row::new(vec![
433 Cell::from("Field").style(self.style.header),
434 Cell::from("Value").style(self.style.header),
435 ]);
436
437 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
479pub 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 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 #[allow(deprecated)]
502 pub fn new(preview: &'a CellPreview) -> Self {
503 Self::from_style(preview, &DebugStyle::default())
504 }
505
506 pub fn label_style(mut self, style: Style) -> Self {
508 self.label_style = style;
509 self
510 }
511
512 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 let char_style = Style::default()
527 .fg(self.preview.fg)
528 .bg(self.preview.bg)
529 .add_modifier(self.preview.modifier);
530
531 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 let char_bg = Style::default().bg(self.bg_surface);
538 let mod_style = Style::default().fg(self.mod_color);
539
540 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#[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 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#[derive(Clone)]
710pub struct ActionLogStyle {
711 pub header: Style,
713 pub sequence: Style,
715 pub name: Style,
717 pub params: Style,
719 pub elapsed: Style,
721 pub selected: Style,
723 pub row_styles: (Style, Style),
725}
726
727impl ActionLogStyle {
728 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
759pub struct ActionLogWidget<'a> {
767 log: &'a ActionLogOverlay,
768 style: ActionLogStyle,
769 visible_rows: usize,
771}
772
773impl<'a> ActionLogWidget<'a> {
774 pub fn new(log: &'a ActionLogOverlay) -> Self {
776 Self {
777 log,
778 style: ActionLogStyle::default(),
779 visible_rows: 10, }
781 }
782
783 pub fn style(mut self, style: ActionLogStyle) -> Self {
785 self.style = style;
786 self
787 }
788
789 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 let visible_rows = (area.height.saturating_sub(1)) as usize;
804
805 let constraints = [
807 Constraint::Length(5), Constraint::Length(20), Constraint::Min(30), Constraint::Length(8), ];
812
813 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 let scroll_offset = self.log.scroll_offset_for(visible_rows);
823
824 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 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(¶ms, &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 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}