transaction-decoder 0.1.13

A CLI tool for decoding EVM transactions
Documentation
use ratatui::{
    layout::{Constraint, Direction, Layout, Rect},
    style::{Color, Modifier, Style},
    text::{Line, Span, Text},
    widgets::{
        Block, BorderType, Borders, Cell, Gauge, List, ListItem, Paragraph, Row, Table, Tabs,
    },
    Frame,
};

use crate::tui::app::{ActiveTab, App};

pub fn draw(f: &mut Frame, app: &mut App) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .margin(1)
        .constraints(
            [
                Constraint::Length(6), // Header
                Constraint::Length(3), // Tabs
                Constraint::Min(0),    // Content
                Constraint::Length(1), // Footer
            ]
            .as_ref(),
        )
        .split(f.area());

    // ASCII Art Header
    let header_text = vec![
        Line::from(Span::styled(
            "  _______  __   ___  ___  ___  ___  ___  ___ ",
            Style::default().fg(Color::Magenta),
        )),
        Line::from(Span::styled(
            " /_  __/ |/ /  / _ \\/ _ \\/ _ \\/ _ \\/ _ \\/ _ \\",
            Style::default().fg(Color::Cyan),
        )),
        Line::from(Span::styled(
            "  / /  |   /  / // / // / // / // / // / // /",
            Style::default().fg(Color::LightGreen),
        )),
        Line::from(Span::styled(
            " /_/   |__|  /____/____/____/____/____/____/ ",
            Style::default().fg(Color::Magenta),
        )),
        Line::from(Span::styled(
            "             TRANSACTION DECODER             ",
            Style::default()
                .fg(Color::White)
                .add_modifier(Modifier::BOLD),
        )),
    ];
    let header = Paragraph::new(header_text)
        .alignment(ratatui::layout::Alignment::Center)
        .block(Block::default().borders(Borders::NONE));
    f.render_widget(header, chunks[0]);

    let titles = vec!["Overview", "Input", "Logs"]
        .iter()
        .map(|t| {
            let (first, rest) = t.split_at(1);
            Line::from(vec![
                Span::styled(first, Style::default().fg(Color::Yellow)),
                Span::styled(rest, Style::default().fg(Color::Green)),
            ])
        })
        .collect::<Vec<Line>>();

    let tabs = Tabs::new(titles)
        .block(
            Block::default()
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded)
                .title("Tabs"),
        )
        .select(app.active_tab as usize)
        .style(Style::default().fg(Color::DarkGray))
        .highlight_style(
            Style::default()
                .fg(Color::Cyan)
                .add_modifier(Modifier::BOLD),
        );

    f.render_widget(tabs, chunks[1]);

    match app.active_tab {
        ActiveTab::Overview => draw_overview(f, app, chunks[2]),
        ActiveTab::Input => draw_input(f, app, chunks[2]),
        ActiveTab::Logs => draw_logs(f, app, chunks[2]),
    }

    let footer = Paragraph::new(
        "Controls: [Tab/h/l] Switch Tabs | [j/k/Up/Down] Scroll (input & params may be below) | [q/Esc] Quit",
    )
    .style(Style::default().fg(Color::DarkGray));
    f.render_widget(footer, chunks[3]);
}

fn draw_overview(f: &mut Frame, app: &mut App, area: Rect) {
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints(
            [
                Constraint::Length(3), // Gas Gauge
                Constraint::Min(0),    // Table
            ]
            .as_ref(),
        )
        .split(area);

    // Gas Gauge
    let gas_ratio = if app.gas_limit > 0 {
        app.gas_used as f64 / app.gas_limit as f64
    } else {
        0.0
    };
    let label = format!(
        "Gas Used: {} / {} ({:.2}%)",
        app.gas_used,
        app.gas_limit,
        gas_ratio * 100.0
    );
    let gauge = Gauge::default()
        .block(
            Block::default()
                .title("Gas Usage")
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded),
        )
        .gauge_style(Style::default().fg(Color::LightGreen).bg(Color::DarkGray))
        .ratio(gas_ratio)
        .label(label)
        .use_unicode(true);
    f.render_widget(gauge, chunks[0]);

    // Calculate available width for the value column (approx 70% of total width - borders/padding)
    let value_width = ((area.width as usize).saturating_mul(70) / 100)
        .saturating_sub(4)
        .max(1);
    let max_row_height = usize::from(chunks[1].height.saturating_sub(2).max(1)); // account for table borders

    let rows = build_rows(&app.overview_details, value_width, max_row_height);

    let table = Table::new(
        rows,
        [Constraint::Percentage(30), Constraint::Percentage(70)],
    )
    .block(
        Block::default()
            .title("Transaction Details (scroll to view input/params)")
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded),
    )
    .widths(&[Constraint::Percentage(30), Constraint::Percentage(70)])
    .column_spacing(1)
    .row_highlight_style(
        Style::default()
            .add_modifier(Modifier::BOLD)
            .bg(Color::DarkGray),
    );

    f.render_stateful_widget(table, chunks[1], &mut app.overview_scroll_state);
}

fn draw_input(f: &mut Frame, app: &mut App, area: Rect) {
    if app.input_details.is_empty() {
        let p = Paragraph::new("No input or params found")
            .block(
                Block::default()
                    .title("Input & Params")
                    .borders(Borders::ALL)
                    .border_type(BorderType::Rounded),
            )
            .style(Style::default().fg(Color::Yellow));
        f.render_widget(p, area);
        return;
    }

    let value_width = ((area.width as usize).saturating_mul(70) / 100)
        .saturating_sub(4)
        .max(1);
    let max_row_height = usize::from(area.height.saturating_sub(2).max(1)); // account for table borders

    let rows = build_rows(&app.input_details, value_width, max_row_height);

    let table = Table::new(
        rows,
        [Constraint::Percentage(30), Constraint::Percentage(70)],
    )
    .block(
        Block::default()
            .title("Input & Params")
            .borders(Borders::ALL)
            .border_type(BorderType::Rounded),
    )
    .widths(&[Constraint::Percentage(30), Constraint::Percentage(70)])
    .column_spacing(1)
    .row_highlight_style(
        Style::default()
            .add_modifier(Modifier::BOLD)
            .bg(Color::DarkGray),
    );

    f.render_stateful_widget(table, area, &mut app.input_scroll_state);
}

fn build_rows(
    data: &[(String, String)],
    value_width: usize,
    max_row_height: usize,
) -> Vec<Row<'_>> {
    data.iter()
        .map(|(k, v)| {
            let options = textwrap::Options::new(value_width)
                .break_words(true)
                .word_splitter(textwrap::WordSplitter::NoHyphenation);
            let wrapped_lines: Vec<String> = textwrap::fill(v, options)
                .lines()
                .map(|line| line.to_string())
                .collect();

            // Clamp very tall rows so the row remains visible.
            let display_lines = if wrapped_lines.len() > max_row_height {
                let mut lines = wrapped_lines
                    .into_iter()
                    .take(max_row_height)
                    .collect::<Vec<_>>();
                if let Some(last) = lines.last_mut() {
                    last.push_str(" ...");
                }
                lines
            } else {
                wrapped_lines
            };

            let height = display_lines.len().max(1) as u16;
            let val_text =
                Text::from(display_lines.join("\n")).style(Style::default().fg(Color::White));
            Row::new(vec![
                Cell::from(Span::styled(k, Style::default().fg(Color::Blue))),
                Cell::from(val_text),
            ])
            .height(height)
        })
        .collect()
}

fn draw_logs(f: &mut Frame, app: &mut App, area: Rect) {
    if app.logs.is_empty() {
        let p = Paragraph::new("No logs found")
            .block(Block::default().title("Logs").borders(Borders::ALL))
            .style(Style::default().fg(Color::Yellow));
        f.render_widget(p, area);
        return;
    }

    // Calculate available width for logs (total width - borders/padding)
    let available_width = area.width as usize - 4;

    let items: Vec<ListItem> = app
        .logs
        .iter()
        .map(|(title, details)| {
            let mut lines = vec![Line::from(Span::styled(
                title,
                Style::default()
                    .add_modifier(Modifier::BOLD)
                    .fg(Color::Magenta),
            ))];
            for (k, v) in details {
                let prefix = format!("{}: ", k);
                let prefix_len = prefix.len();
                let wrap_width = if available_width > prefix_len {
                    available_width - prefix_len
                } else {
                    available_width
                };

                let options = textwrap::Options::new(wrap_width)
                    .break_words(true)
                    .word_splitter(textwrap::WordSplitter::NoHyphenation);
                let wrapped_val = textwrap::fill(v, options);
                let mut val_lines = wrapped_val.lines();

                if let Some(first_line) = val_lines.next() {
                    lines.push(Line::from(vec![
                        Span::styled(prefix.clone(), Style::default().fg(Color::Blue)),
                        Span::styled(first_line.to_string(), Style::default().fg(Color::White)),
                    ]));
                } else {
                    lines.push(Line::from(vec![Span::styled(
                        prefix.clone(),
                        Style::default().fg(Color::Blue),
                    )]));
                }

                for line in val_lines {
                    lines.push(Line::from(vec![
                        Span::raw(" ".repeat(prefix_len)),
                        Span::styled(line.to_string(), Style::default().fg(Color::White)),
                    ]));
                }
            }
            lines.push(Line::from("")); // Separator
            ListItem::new(lines)
        })
        .collect();

    let list = List::new(items)
        .block(
            Block::default()
                .title("Logs")
                .borders(Borders::ALL)
                .border_type(BorderType::Rounded),
        )
        .highlight_style(
            Style::default()
                .add_modifier(Modifier::BOLD)
                .bg(Color::DarkGray),
        );

    f.render_stateful_widget(list, area, &mut app.logs_scroll_state);
}