grok-build-upstream-mirror/crates/codegen/xai-grok-markdown/bin/md_table_test.rs
grokkybara[bot] c68e39f604 Publish harness and TUI open-source
initial sync from the monorepo
2026-07-16 06:46:02 +01:00

468 lines
15 KiB
Rust

//! Interactive markdown table rendering playground.
//!
//! Run with:
//! cargo run -p xai-grok-markdown --features playground --bin md-table-test
//!
//! Controls:
//! Space — toggle textarea focus
//! Alt/Ctrl/Shift+Enter — submit markdown & defocus (when textarea focused)
//! h / Left — shrink render width (when unfocused)
//! l / Right — grow render width (when unfocused)
//! Esc — quit (always)
use std::io::{self, stdout};
use std::time::Duration;
use crossterm::ExecutableCommand;
use crossterm::event::{
self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyModifiers,
};
use crossterm::terminal::{self, EnterAlternateScreen, LeaveAlternateScreen};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, StatefulWidgetRef, Wrap};
use xai_grok_markdown::{
MarkdownBuffers, MarkdownStyle, StreamingMarkdownRenderer,
render_markdown_ratatui_with_buffers_width,
};
use xai_ratatui_textarea::{TextArea, TextAreaState};
// ── Tokyo Night Storm palette (matches xai-grok-pager) ──────────────────────
#[path = "playground_common.rs"]
mod playground_common;
use playground_common::{get_syntect, md_style};
const MD_STYLE: MarkdownStyle = md_style(anstyle::Style::new());
// ── Compute minimum render width ─────────────────────────────────────────────
/// Minimum width = 4k + 1, where k = number of table columns.
/// Columns = max(`|` count per line) - 1 (the outer pipes are borders).
/// This ensures every column gets at least 1 char + padding. Floor of 10.
fn min_render_width(source: &str) -> usize {
let max_pipes = source
.lines()
.map(|line| line.chars().filter(|&c| c == '|').count())
.max()
.unwrap_or(0);
let num_cols = max_pipes.saturating_sub(1);
if num_cols == 0 {
10
} else {
(4 * num_cols + 1).max(10)
}
}
// ── Render helpers ───────────────────────────────────────────────────────────
/// One-shot full render at a given width.
fn render_full(source: &str, width: usize) -> Vec<Line<'static>> {
let mut buffers = MarkdownBuffers::new();
let (output, _) = render_markdown_ratatui_with_buffers_width(
source,
MD_STYLE,
true,
&mut buffers,
Some(get_syntect()),
Some(width),
);
output.lines
}
/// Streaming render: feed each char individually, return final lines.
fn render_streaming(source: &str, width: usize) -> Vec<Line<'static>> {
let mut renderer = StreamingMarkdownRenderer::new(MD_STYLE, true);
renderer.set_max_table_width(Some(width));
for ch in source.chars() {
renderer.push_and_render(&ch.to_string(), Some(get_syntect()));
}
renderer.view().lines.to_vec()
}
// ── App state ────────────────────────────────────────────────────────────────
const DEFAULT_MARKDOWN: &str = "\
| A | B | C |
|---|---|---|
| 1 | 2 | 3 |
";
struct App {
textarea: TextArea,
textarea_state: TextAreaState,
textarea_focused: bool,
textarea_area: Rect,
render_width: usize,
source: String,
full_lines: Vec<Line<'static>>,
streaming_lines: Vec<Line<'static>>,
}
impl App {
fn new() -> Self {
let mut textarea = TextArea::new();
textarea.set_text(DEFAULT_MARKDOWN);
textarea.show_scrollbar = false;
let source = DEFAULT_MARKDOWN.to_string();
let width = 24usize;
let full_lines = render_full(&source, width);
let streaming_lines = render_streaming(&source, width);
Self {
textarea,
textarea_state: TextAreaState::default(),
textarea_focused: true,
textarea_area: Rect::default(),
render_width: width,
source,
full_lines,
streaming_lines,
}
}
fn rerender(&mut self) {
self.source = self.textarea.text().to_string();
let min_w = min_render_width(&self.source);
if self.render_width < min_w {
self.render_width = min_w;
}
self.full_lines = render_full(&self.source, self.render_width);
self.streaming_lines = render_streaming(&self.source, self.render_width);
}
fn adjust_width(&mut self, delta: isize) {
let min_w = min_render_width(&self.source);
let new_w = (self.render_width as isize + delta).max(min_w as isize) as usize;
if new_w != self.render_width {
self.render_width = new_w;
self.full_lines = render_full(&self.source, self.render_width);
self.streaming_lines = render_streaming(&self.source, self.render_width);
}
}
}
// ── Main ─────────────────────────────────────────────────────────────────────
fn main() -> io::Result<()> {
// Terminal setup
terminal::enable_raw_mode()?;
stdout().execute(EnterAlternateScreen)?;
stdout().execute(EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout());
let mut terminal = Terminal::new(backend)?;
let mut app = App::new();
loop {
terminal.draw(|f| draw(f, &mut app))?;
if event::poll(Duration::from_millis(100))? {
let ev = event::read()?;
match &ev {
// ── Global: Ctrl-Q quits from anywhere ──
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
modifiers: KeyModifiers::CONTROL,
..
}) => break,
// ── Focused: defocus on Esc or Tab ──
Event::Key(KeyEvent {
code: KeyCode::Esc | KeyCode::Tab,
..
}) if app.textarea_focused => {
app.textarea_focused = false;
}
// ── Unfocused: quit on q, Ctrl-C, Ctrl-D ──
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
..
}) if !app.textarea_focused => break,
Event::Key(KeyEvent {
code: KeyCode::Char('c') | KeyCode::Char('d'),
modifiers: KeyModifiers::CONTROL,
..
}) if !app.textarea_focused => break,
// ── Unfocused: Space or Enter to re-focus ──
Event::Key(KeyEvent {
code: KeyCode::Char(' ') | KeyCode::Enter,
..
}) if !app.textarea_focused => {
app.textarea_focused = true;
}
// ── Unfocused: width controls ──
Event::Key(KeyEvent {
code: KeyCode::Char('h') | KeyCode::Left,
..
}) if !app.textarea_focused => {
app.adjust_width(-1);
}
Event::Key(KeyEvent {
code: KeyCode::Char('l') | KeyCode::Right,
..
}) if !app.textarea_focused => {
app.adjust_width(1);
}
Event::Key(KeyEvent {
code: KeyCode::Char('H'),
..
}) if !app.textarea_focused => {
app.adjust_width(-5);
}
Event::Key(KeyEvent {
code: KeyCode::Char('L'),
..
}) if !app.textarea_focused => {
app.adjust_width(5);
}
// ── Focused: forward keys to textarea, live re-render ──
Event::Key(key) if app.textarea_focused => {
app.textarea.input(*key);
app.rerender();
}
// ── Focused: forward mouse to textarea ──
Event::Mouse(mouse) if app.textarea_focused => {
app.textarea
.handle_mouse(*mouse, app.textarea_area, app.textarea_state);
}
_ => {}
}
}
}
// Terminal cleanup
stdout().execute(DisableMouseCapture)?;
stdout().execute(LeaveAlternateScreen)?;
terminal::disable_raw_mode()?;
Ok(())
}
// ── Drawing ──────────────────────────────────────────────────────────────────
fn draw(f: &mut ratatui::Frame, app: &mut App) {
let size = f.area();
// Layout: header (1) | textarea (flexible) | full render | streaming render
//
// We compute the heights we need for the render panels, then give the rest
// to the textarea.
let render_w = app.render_width as u16;
let full_height = wrapped_line_count(&app.full_lines, render_w).max(1) + 2; // +2 for border
let stream_height = wrapped_line_count(&app.streaming_lines, render_w).max(1) + 2;
// Detect mismatches between full and streaming
let mismatch = detect_mismatch(&app.full_lines, &app.streaming_lines);
let header_height = 2u16;
let render_height = full_height + stream_height;
let textarea_min = 5u16;
let textarea_height = size
.height
.saturating_sub(header_height + render_height)
.max(textarea_min);
let chunks = Layout::vertical([
Constraint::Length(header_height),
Constraint::Length(textarea_height),
Constraint::Length(full_height),
Constraint::Length(stream_height),
])
.split(size);
// ── Header ──
let focus_indicator = if app.textarea_focused {
Span::styled(
" EDITING ",
Style::default().fg(Color::Black).bg(Color::Green),
)
} else {
Span::styled(
" VIEW ",
Style::default().fg(Color::Black).bg(Color::Yellow),
)
};
let width_info = Span::styled(
format!(
" width: {} (min: {}) ",
app.render_width,
min_render_width(&app.source)
),
Style::default().fg(Color::Cyan),
);
let keys = if app.textarea_focused {
Span::styled(
" (live) Esc/Tab: defocus ",
Style::default().fg(Color::DarkGray),
)
} else {
Span::styled(
" Space/Enter: edit | h/l: width -/+ | H/L: -5/+5 | q/^C/^D: quit ",
Style::default().fg(Color::DarkGray),
)
};
let mismatch_indicator = if mismatch {
Span::styled(
" MISMATCH! ",
Style::default()
.fg(Color::White)
.bg(Color::Red)
.add_modifier(Modifier::BOLD),
)
} else {
Span::styled(" OK ", Style::default().fg(Color::Black).bg(Color::Green))
};
let header = Paragraph::new(vec![
Line::from(vec![
focus_indicator,
Span::raw(" "),
width_info,
Span::raw(" "),
mismatch_indicator,
]),
Line::from(keys),
]);
f.render_widget(header, chunks[0]);
// ── Textarea ──
let border_color = if app.textarea_focused {
Color::Green
} else {
Color::DarkGray
};
let textarea_block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
" Markdown Input ",
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
let textarea_inner = textarea_block.inner(chunks[1]);
f.render_widget(textarea_block, chunks[1]);
app.textarea_area = textarea_inner;
(&app.textarea).render_ref(textarea_inner, f.buffer_mut(), &mut app.textarea_state);
if app.textarea_focused
&& let Some((cx, cy)) = app
.textarea
.cursor_pos_with_state(textarea_inner, app.textarea_state)
{
f.set_cursor_position((cx, cy));
}
// ── Full render panel ──
let full_title = format!(" full: {} ", app.render_width);
render_panel(f, chunks[2], &full_title, &app.full_lines, render_w, false);
// ── Streaming render panel ──
let stream_title = format!(" stream: {} ", app.render_width);
render_panel(
f,
chunks[3],
&stream_title,
&app.streaming_lines,
render_w,
mismatch,
);
}
/// Count the number of visual rows a set of lines occupies when soft-wrapped
/// to `width` columns. Each line takes ceil(display_width / width) rows,
/// with a minimum of 1 row per line (empty lines still occupy a row).
fn wrapped_line_count(lines: &[Line<'_>], width: u16) -> u16 {
use unicode_width::UnicodeWidthStr;
if width == 0 {
return lines.len() as u16;
}
let w = width as usize;
lines
.iter()
.map(|line| {
let display_w: usize = line
.spans
.iter()
.map(|s| UnicodeWidthStr::width(s.content.as_ref()))
.sum();
if display_w == 0 {
1u16
} else {
display_w.div_ceil(w) as u16 // ceil division
}
})
.sum()
}
/// Render a bordered panel whose *inner* width is exactly `inner_w`.
///
/// Content is soft-wrapped with `Wrap { trim: false }` so long non-table
/// lines fold inside the box. The box is left-aligned within the available
/// `area`. If `is_error` is true the border turns red.
fn render_panel(
f: &mut ratatui::Frame,
area: Rect,
title: &str,
lines: &[Line<'static>],
inner_w: u16,
is_error: bool,
) {
// The block border adds 1 column on each side, so outer width = inner_w + 2.
let outer_w = (inner_w + 2).min(area.width);
let box_area = Rect {
x: area.x,
y: area.y,
width: outer_w,
height: area.height,
};
let border_color = if is_error {
Color::Red
} else {
Color::DarkGray
};
let block = Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color))
.title(Span::styled(
title.to_string(),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
));
let inner = block.inner(box_area);
f.render_widget(block, box_area);
let para = Paragraph::new(lines.to_vec()).wrap(Wrap { trim: false });
f.render_widget(para, inner);
}
/// Detect if full and streaming outputs differ in text content.
fn detect_mismatch(full: &[Line<'static>], streaming: &[Line<'static>]) -> bool {
if full.len() != streaming.len() {
return true;
}
for (f_line, s_line) in full.iter().zip(streaming.iter()) {
let f_text: String = f_line.spans.iter().map(|s| s.content.as_ref()).collect();
let s_text: String = s_line.spans.iter().map(|s| s.content.as_ref()).collect();
if f_text != s_text {
return true;
}
}
false
}