use ratatui::buffer::Buffer;
use ratatui::layout::{Constraint, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Cell, Clear, Paragraph, Row, Table, Widget};
use ratatui::Frame;
use super::cell::{format_color_compact, format_modifier_compact, CellPreview};
use super::config::DebugStyle;
use super::table::{ActionLogOverlay, DebugTableOverlay, DebugTableRow};
pub fn buffer_to_text(buffer: &Buffer) -> String {
let area = buffer.area;
let mut out = String::new();
for y in area.y..area.y.saturating_add(area.height) {
let mut line = String::new();
for x in area.x..area.x.saturating_add(area.width) {
line.push_str(buffer[(x, y)].symbol());
}
out.push_str(line.trim_end_matches(' '));
if y + 1 < area.y.saturating_add(area.height) {
out.push('\n');
}
}
out
}
pub fn paint_snapshot(f: &mut Frame, snapshot: &Buffer) {
let screen = f.area();
f.render_widget(Clear, screen);
let snap_area = snapshot.area;
let x_end = screen
.x
.saturating_add(screen.width)
.min(snap_area.x.saturating_add(snap_area.width));
let y_end = screen
.y
.saturating_add(screen.height)
.min(snap_area.y.saturating_add(snap_area.height));
for y in screen.y..y_end {
for x in screen.x..x_end {
f.buffer_mut()[(x, y)] = snapshot[(x, y)].clone();
}
}
}
pub fn dim_buffer(buffer: &mut Buffer, factor: f32) {
let factor = factor.clamp(0.0, 1.0);
let scale = 1.0 - factor;
for cell in buffer.content.iter_mut() {
if contains_emoji(cell.symbol()) {
cell.set_symbol(" ");
}
cell.fg = dim_color(cell.fg, scale);
cell.bg = dim_color(cell.bg, scale);
}
}
fn contains_emoji(s: &str) -> bool {
for c in s.chars() {
if is_emoji(c) {
return true;
}
}
false
}
fn is_emoji(c: char) -> bool {
let cp = c as u32;
matches!(cp,
0x1F300..=0x1F5FF |
0x1F600..=0x1F64F |
0x1F680..=0x1F6FF |
0x1F900..=0x1F9FF |
0x1FA00..=0x1FA6F |
0x1FA70..=0x1FAFF |
0x1F1E0..=0x1F1FF
)
}
fn dim_color(color: ratatui::style::Color, scale: f32) -> ratatui::style::Color {
use ratatui::style::Color;
match color {
Color::Rgb(r, g, b) => Color::Rgb(
((r as f32) * scale) as u8,
((g as f32) * scale) as u8,
((b as f32) * scale) as u8,
),
Color::Indexed(idx) => {
if let Some((r, g, b)) = indexed_to_rgb(idx) {
Color::Rgb(
((r as f32) * scale) as u8,
((g as f32) * scale) as u8,
((b as f32) * scale) as u8,
)
} else {
color }
}
Color::Black => Color::Black,
Color::Red => dim_named_color(205, 0, 0, scale),
Color::Green => dim_named_color(0, 205, 0, scale),
Color::Yellow => dim_named_color(205, 205, 0, scale),
Color::Blue => dim_named_color(0, 0, 238, scale),
Color::Magenta => dim_named_color(205, 0, 205, scale),
Color::Cyan => dim_named_color(0, 205, 205, scale),
Color::Gray => dim_named_color(229, 229, 229, scale),
Color::DarkGray => dim_named_color(127, 127, 127, scale),
Color::LightRed => dim_named_color(255, 0, 0, scale),
Color::LightGreen => dim_named_color(0, 255, 0, scale),
Color::LightYellow => dim_named_color(255, 255, 0, scale),
Color::LightBlue => dim_named_color(92, 92, 255, scale),
Color::LightMagenta => dim_named_color(255, 0, 255, scale),
Color::LightCyan => dim_named_color(0, 255, 255, scale),
Color::White => dim_named_color(255, 255, 255, scale),
Color::Reset => Color::Reset,
}
}
fn dim_named_color(r: u8, g: u8, b: u8, scale: f32) -> ratatui::style::Color {
ratatui::style::Color::Rgb(
((r as f32) * scale) as u8,
((g as f32) * scale) as u8,
((b as f32) * scale) as u8,
)
}
fn indexed_to_rgb(idx: u8) -> Option<(u8, u8, u8)> {
match idx {
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 => {
let idx = idx - 16;
let r = (idx / 36) % 6;
let g = (idx / 6) % 6;
let b = idx % 6;
let to_rgb = |v: u8| if v == 0 { 0 } else { 55 + v * 40 };
Some((to_rgb(r), to_rgb(g), to_rgb(b)))
}
232..=255 => {
let gray = 8 + (idx - 232) * 10;
Some((gray, gray, gray))
}
}
}
#[derive(Clone)]
pub struct BannerItem<'a> {
pub key: &'a str,
pub label: &'a str,
pub key_style: Style,
}
impl<'a> BannerItem<'a> {
pub fn new(key: &'a str, label: &'a str, key_style: Style) -> Self {
Self {
key,
label,
key_style,
}
}
}
pub struct DebugBanner<'a> {
title: Option<&'a str>,
title_style: Style,
items: Vec<BannerItem<'a>>,
label_style: Style,
background: Style,
}
impl<'a> Default for DebugBanner<'a> {
fn default() -> Self {
Self::new()
}
}
impl<'a> DebugBanner<'a> {
pub fn new() -> Self {
Self {
title: None,
title_style: Style::default(),
items: Vec::new(),
label_style: Style::default(),
background: Style::default(),
}
}
pub fn title(mut self, title: &'a str) -> Self {
self.title = Some(title);
self
}
pub fn title_style(mut self, style: Style) -> Self {
self.title_style = style;
self
}
pub fn item(mut self, item: BannerItem<'a>) -> Self {
self.items.push(item);
self
}
pub fn label_style(mut self, style: Style) -> Self {
self.label_style = style;
self
}
pub fn background(mut self, style: Style) -> Self {
self.background = style;
self
}
}
impl Widget for DebugBanner<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height == 0 || area.width == 0 {
return;
}
for y in area.y..area.y.saturating_add(area.height) {
for x in area.x..area.x.saturating_add(area.width) {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(" ");
cell.set_style(self.background);
}
}
}
let mut spans = Vec::new();
if let Some(title) = self.title {
spans.push(Span::styled(format!(" {title} "), self.title_style));
spans.push(Span::raw(" "));
}
for item in &self.items {
spans.push(Span::styled(format!(" {} ", item.key), item.key_style));
spans.push(Span::styled(format!(" {} ", item.label), self.label_style));
}
let line = Paragraph::new(Line::from(spans)).style(self.background);
line.render(area, buf);
}
}
#[derive(Clone)]
pub struct DebugTableStyle {
pub header: Style,
pub section: Style,
pub key: Style,
pub value: Style,
pub row_styles: (Style, Style),
}
impl DebugTableStyle {
pub fn from_style(s: &super::config::DebugStyle) -> Self {
Self {
header: Style::default()
.fg(s.accent)
.bg(s.overlay_bg_dark)
.add_modifier(Modifier::BOLD),
section: Style::default()
.fg(s.neon_purple)
.bg(s.overlay_bg_dark)
.add_modifier(Modifier::BOLD),
key: Style::default()
.fg(s.neon_amber)
.add_modifier(Modifier::BOLD),
value: Style::default().fg(s.text_primary),
row_styles: (
Style::default().bg(s.overlay_bg),
Style::default().bg(s.overlay_bg_alt),
),
}
}
}
#[allow(deprecated)]
impl Default for DebugTableStyle {
fn default() -> Self {
Self::from_style(&super::config::DebugStyle::default())
}
}
pub struct DebugTableWidget<'a> {
table: &'a DebugTableOverlay,
style: DebugTableStyle,
scroll_offset: usize,
}
impl<'a> DebugTableWidget<'a> {
pub fn new(table: &'a DebugTableOverlay) -> Self {
Self {
table,
style: DebugTableStyle::default(),
scroll_offset: 0,
}
}
pub fn style(mut self, style: DebugTableStyle) -> Self {
self.style = style;
self
}
pub fn scroll_offset(mut self, scroll_offset: usize) -> Self {
self.scroll_offset = scroll_offset;
self
}
}
impl Widget for DebugTableWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 2 || area.width < 10 {
return;
}
let max_key_len = self
.table
.rows
.iter()
.filter_map(|row| match row {
DebugTableRow::Entry { key, .. } => Some(key.chars().count()),
DebugTableRow::Section(_) => None,
})
.max()
.unwrap_or(0) as u16;
let max_label = area.width.saturating_sub(8).max(10);
let label_width = max_key_len.saturating_add(2).clamp(12, 30).min(max_label);
let constraints = [Constraint::Length(label_width), Constraint::Min(0)];
let header = Row::new(vec![
Cell::from("Field").style(self.style.header),
Cell::from("Value").style(self.style.header),
]);
let visible_rows = area.height.saturating_sub(1) as usize;
let max_offset = self.table.rows.len().saturating_sub(visible_rows);
let scroll_offset = self.scroll_offset.min(max_offset);
let rows: Vec<Row> = self
.table
.rows
.iter()
.skip(scroll_offset)
.enumerate()
.map(|(idx, row)| match row {
DebugTableRow::Section(title) => Row::new(vec![
Cell::from(format!(" {title} ")).style(self.style.section),
Cell::from(""),
]),
DebugTableRow::Entry { key, value } => {
let row_index = idx + scroll_offset;
let row_style = if row_index % 2 == 0 {
self.style.row_styles.0
} else {
self.style.row_styles.1
};
let syntax_style = DebugSyntaxStyle::with_base(self.style.value);
let value_line = Line::from(debug_spans(value, &syntax_style));
Row::new(vec![
Cell::from(key.clone()).style(self.style.key),
Cell::from(value_line).style(self.style.value),
])
.style(row_style)
}
})
.collect();
let table = Table::new(rows, constraints)
.header(header)
.column_spacing(2);
table.render(area, buf);
}
}
pub struct CellPreviewWidget<'a> {
preview: &'a CellPreview,
label_style: Style,
value_style: Style,
bg_surface: ratatui::style::Color,
mod_color: ratatui::style::Color,
}
impl<'a> CellPreviewWidget<'a> {
pub fn from_style(preview: &'a CellPreview, s: &DebugStyle) -> Self {
Self {
preview,
label_style: Style::default().fg(s.text_secondary),
value_style: Style::default().fg(s.text_primary),
bg_surface: s.bg_surface,
mod_color: s.neon_purple,
}
}
#[allow(deprecated)]
pub fn new(preview: &'a CellPreview) -> Self {
Self::from_style(preview, &DebugStyle::default())
}
pub fn label_style(mut self, style: Style) -> Self {
self.label_style = style;
self
}
pub fn value_style(mut self, style: Style) -> Self {
self.value_style = style;
self
}
}
impl Widget for CellPreviewWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.width < 20 || area.height < 1 {
return;
}
let char_style = Style::default()
.fg(self.preview.fg)
.bg(self.preview.bg)
.add_modifier(self.preview.modifier);
let fg_str = format_color_compact(self.preview.fg);
let bg_str = format_color_compact(self.preview.bg);
let mod_str = format_modifier_compact(self.preview.modifier);
let char_bg = Style::default().bg(self.bg_surface);
let mod_style = Style::default().fg(self.mod_color);
let mut spans = vec![
Span::styled(" ", char_bg),
Span::styled(self.preview.symbol.clone(), char_style),
Span::styled(" ", char_bg),
Span::styled(" fg ", self.label_style),
Span::styled("█", Style::default().fg(self.preview.fg)),
Span::styled(format!(" {fg_str}"), self.value_style),
Span::styled(" bg ", self.label_style),
Span::styled("█", Style::default().fg(self.preview.bg)),
Span::styled(format!(" {bg_str}"), self.value_style),
];
if !mod_str.is_empty() {
spans.push(Span::styled(format!(" {mod_str}"), mod_style));
}
let line = Paragraph::new(Line::from(spans));
line.render(area, buf);
}
}
#[derive(Clone)]
pub(crate) struct DebugSyntaxStyle {
punctuation: Style,
string: Style,
number: Style,
keyword: Style,
identifier: Style,
fallback: Style,
}
impl DebugSyntaxStyle {
pub(crate) fn from_style(s: &DebugStyle, base: Style) -> Self {
Self {
punctuation: Style::default().fg(s.text_secondary),
string: Style::default().fg(s.neon_green),
number: Style::default().fg(s.neon_cyan),
keyword: Style::default().fg(s.neon_purple),
identifier: base,
fallback: base,
}
}
#[allow(deprecated)]
pub(crate) fn with_base(base: Style) -> Self {
Self::from_style(&DebugStyle::default(), base)
}
}
pub(crate) fn debug_spans(value: &str, style: &DebugSyntaxStyle) -> Vec<Span<'static>> {
let mut spans: Vec<Span<'static>> = Vec::new();
let mut chars = value.chars().peekable();
#[allow(clippy::while_let_on_iterator)]
while let Some(ch) = chars.next() {
if ch == '"' || ch == '\'' {
let quote = ch;
let mut token = String::from(quote);
let mut escaped = false;
while let Some(next) = chars.next() {
token.push(next);
if escaped {
escaped = false;
continue;
}
if next == '\\' {
escaped = true;
continue;
}
if next == quote {
break;
}
}
spans.push(Span::styled(token, style.string));
continue;
}
if is_debug_punctuation(ch) {
spans.push(Span::styled(ch.to_string(), style.punctuation));
continue;
}
if ch.is_whitespace() {
spans.push(Span::styled(ch.to_string(), style.fallback));
continue;
}
if is_number_start(ch, chars.peek()) {
let mut token = String::new();
token.push(ch);
while let Some(&next) = chars.peek() {
if is_number_char(next) {
token.push(next);
chars.next();
} else {
break;
}
}
spans.push(Span::styled(token, style.number));
continue;
}
if is_ident_start(ch) {
let mut token = String::new();
token.push(ch);
while let Some(&next) = chars.peek() {
if is_ident_char(next) {
token.push(next);
chars.next();
} else {
break;
}
}
let token_style = if is_debug_keyword(&token) {
style.keyword
} else {
style.identifier
};
spans.push(Span::styled(token, token_style));
continue;
}
spans.push(Span::styled(ch.to_string(), style.fallback));
}
spans
}
fn is_debug_punctuation(ch: char) -> bool {
matches!(ch, '{' | '}' | '[' | ']' | '(' | ')' | ':' | ',' | '=')
}
fn is_number_start(ch: char, next: Option<&char>) -> bool {
if ch.is_ascii_digit() {
return true;
}
if ch == '-' {
return next.map(|c| c.is_ascii_digit()).unwrap_or(false);
}
false
}
fn is_number_char(ch: char) -> bool {
ch.is_ascii_digit() || matches!(ch, '.' | '_' | '+' | '-' | 'e' | 'E')
}
fn is_ident_start(ch: char) -> bool {
ch.is_ascii_alphabetic() || ch == '_'
}
fn is_ident_char(ch: char) -> bool {
ch.is_ascii_alphanumeric() || ch == '_' || ch == '-'
}
fn is_debug_keyword(token: &str) -> bool {
matches!(token, "true" | "false" | "None" | "Some" | "null")
}
#[derive(Clone)]
pub struct ActionLogStyle {
pub header: Style,
pub sequence: Style,
pub name: Style,
pub params: Style,
pub elapsed: Style,
pub selected: Style,
pub row_styles: (Style, Style),
}
impl ActionLogStyle {
pub fn from_style(s: &super::config::DebugStyle) -> Self {
Self {
header: Style::default()
.fg(s.accent)
.bg(s.overlay_bg_dark)
.add_modifier(Modifier::BOLD),
sequence: Style::default().fg(s.text_secondary),
name: Style::default()
.fg(s.neon_amber)
.add_modifier(Modifier::BOLD),
params: Style::default().fg(s.text_primary),
elapsed: Style::default().fg(s.text_secondary),
selected: Style::default()
.bg(s.bg_highlight)
.add_modifier(Modifier::BOLD),
row_styles: (
Style::default().bg(s.overlay_bg),
Style::default().bg(s.overlay_bg_alt),
),
}
}
}
#[allow(deprecated)]
impl Default for ActionLogStyle {
fn default() -> Self {
Self::from_style(&super::config::DebugStyle::default())
}
}
pub struct ActionLogWidget<'a> {
log: &'a ActionLogOverlay,
style: ActionLogStyle,
visible_rows: usize,
}
impl<'a> ActionLogWidget<'a> {
pub fn new(log: &'a ActionLogOverlay) -> Self {
Self {
log,
style: ActionLogStyle::default(),
visible_rows: 10, }
}
pub fn style(mut self, style: ActionLogStyle) -> Self {
self.style = style;
self
}
pub fn visible_rows(mut self, rows: usize) -> Self {
self.visible_rows = rows;
self
}
}
impl Widget for ActionLogWidget<'_> {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height < 2 || area.width < 30 {
return;
}
let visible_rows = (area.height.saturating_sub(1)) as usize;
let constraints = [
Constraint::Length(5), Constraint::Length(20), Constraint::Min(30), Constraint::Length(8), ];
let header = Row::new(vec![
Cell::from("#").style(self.style.header),
Cell::from("Action").style(self.style.header),
Cell::from("Params").style(self.style.header),
Cell::from("Elapsed").style(self.style.header),
]);
let scroll_offset = self.log.scroll_offset_for(visible_rows);
let rows: Vec<Row> = self
.log
.entries
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_rows)
.map(|(idx, entry)| {
let is_selected = idx == self.log.selected;
let base_style = if is_selected {
self.style.selected
} else if idx % 2 == 0 {
self.style.row_styles.0
} else {
self.style.row_styles.1
};
let params_compact = entry.params.replace('\n', " ");
let params_compact = params_compact
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let params = if params_compact.chars().count() > 60 {
let truncated: String = params_compact.chars().take(57).collect();
format!("{}...", truncated)
} else {
params_compact
};
let syntax_style = DebugSyntaxStyle::with_base(self.style.params);
let params_line = Line::from(debug_spans(¶ms, &syntax_style));
Row::new(vec![
Cell::from(format!("{}", entry.sequence)).style(self.style.sequence),
Cell::from(entry.name.clone()).style(self.style.name),
Cell::from(Text::from(params_line)).style(self.style.params),
Cell::from(entry.elapsed.clone()).style(self.style.elapsed),
])
.style(base_style)
})
.collect();
let table = Table::new(rows, constraints)
.header(header)
.column_spacing(1);
table.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::layout::Rect;
#[test]
fn test_buffer_to_text() {
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 3));
buffer[(0, 0)].set_char('H');
buffer[(1, 0)].set_char('i');
buffer[(0, 1)].set_char('!');
let text = buffer_to_text(&buffer);
let lines: Vec<&str> = text.lines().collect();
assert_eq!(lines[0], "Hi");
assert_eq!(lines[1], "!");
}
#[test]
fn test_debug_banner() {
let banner =
DebugBanner::new()
.title("TEST")
.item(BannerItem::new("F1", "help", Style::default()));
let mut buffer = Buffer::empty(Rect::new(0, 0, 40, 1));
banner.render(Rect::new(0, 0, 40, 1), &mut buffer);
let text = buffer_to_text(&buffer);
assert!(text.contains("TEST"));
assert!(text.contains("F1"));
assert!(text.contains("help"));
}
}