use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
pub fn render_preview(
f: &mut Frame,
area: Rect,
content: &str,
ext: &str,
title: Line<'static>,
scroll: u16,
focused: bool,
) {
let border = if focused {
Color::Cyan
} else {
Color::DarkGray
};
let block = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(Style::default().fg(border));
let lines = match ext {
"json" => highlight_json(content),
_ => highlight(content),
};
let paragraph = Paragraph::new(lines)
.block(block)
.wrap(Wrap { trim: false })
.scroll((scroll, 0));
f.render_widget(paragraph, area);
}
fn highlight_json(content: &str) -> Vec<Line<'static>> {
let pretty = match serde_json::from_str::<serde_json::Value>(content) {
Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| content.to_string()),
Err(_) => {
let mut out = vec![Line::styled(
"(invalid JSON - showing raw)".to_string(),
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::ITALIC),
)];
out.extend(content.lines().map(|l| Line::raw(l.to_string())));
return out;
}
};
pretty.lines().map(highlight_json_line).collect()
}
fn highlight_json_line(line: &str) -> Line<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
let bytes = line.as_bytes();
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' {
let start = i;
i += 1;
while i < bytes.len() && bytes[i] != b'"' {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
i += 2;
} else {
i += 1;
}
}
if i < bytes.len() {
i += 1; }
let mut j = i;
while j < bytes.len() && bytes[j].is_ascii_whitespace() {
j += 1;
}
let is_key = j < bytes.len() && bytes[j] == b':';
let style = if is_key {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
spans.push(Span::styled(line[start..i].to_string(), style));
} else if c.is_ascii_digit()
|| (c == b'-' && bytes.get(i + 1).is_some_and(|n| n.is_ascii_digit()))
{
let start = i;
i += 1;
while i < bytes.len()
&& (bytes[i].is_ascii_digit()
|| matches!(bytes[i], b'.' | b'e' | b'E' | b'+' | b'-'))
{
i += 1;
}
spans.push(Span::styled(
line[start..i].to_string(),
Style::default().fg(Color::Yellow),
));
} else if line[i..].starts_with("true") {
spans.push(Span::styled(
"true".to_string(),
Style::default().fg(Color::Magenta),
));
i += 4;
} else if line[i..].starts_with("false") {
spans.push(Span::styled(
"false".to_string(),
Style::default().fg(Color::Magenta),
));
i += 5;
} else if line[i..].starts_with("null") {
spans.push(Span::styled(
"null".to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::ITALIC),
));
i += 4;
} else if matches!(c, b'{' | b'}' | b'[' | b']' | b',' | b':') {
spans.push(Span::styled(
(c as char).to_string(),
Style::default().fg(Color::DarkGray),
));
i += 1;
} else {
let ch_end = line[i..]
.char_indices()
.nth(1)
.map(|(j, _)| i + j)
.unwrap_or(line.len());
spans.push(Span::raw(line[i..ch_end].to_string()));
i = ch_end;
}
}
Line::from(spans)
}
fn highlight(content: &str) -> Vec<Line<'static>> {
let mut out = Vec::new();
let mut in_code_block = false;
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let trimmed = line.trim_start();
if trimmed.starts_with("```") {
in_code_block = !in_code_block;
out.push(Line::styled(
line.to_string(),
Style::default().fg(Color::Green),
));
i += 1;
continue;
}
if in_code_block {
out.push(Line::styled(
line.to_string(),
Style::default().fg(Color::Green),
));
i += 1;
continue;
}
if let Some(consumed) = try_render_table(&lines, i, &mut out) {
i += consumed;
continue;
}
let pieces = split_br(line);
for (n, sub) in pieces.iter().enumerate() {
let s = if n == 0 {
sub.as_str()
} else {
sub.trim_start()
};
let sub_trimmed = s.trim_start();
out.push(highlight_line(s, sub_trimmed));
}
i += 1;
}
out
}
fn try_render_table(lines: &[&str], start: usize, out: &mut Vec<Line<'static>>) -> Option<usize> {
if start + 1 >= lines.len() {
return None;
}
let header_raw = lines[start].trim();
let sep_raw = lines[start + 1].trim();
if !is_table_row(header_raw) || !is_table_separator(sep_raw) {
return None;
}
let header_cells: Vec<Vec<String>> = split_cells(header_raw)
.into_iter()
.map(|c| split_br(&c))
.collect();
let col_count = header_cells.len();
if col_count == 0 {
return None;
}
let mut body: Vec<Vec<Vec<String>>> = Vec::new();
let mut idx = start + 2;
while idx < lines.len() {
let row = lines[idx].trim();
if !is_table_row(row) {
break;
}
let mut cells = split_cells(row);
cells.resize(col_count, String::new());
let split: Vec<Vec<String>> = cells.iter().map(|c| split_br(c)).collect();
body.push(split);
idx += 1;
}
let mut widths: Vec<usize> = vec![1; col_count];
let measure = |sub_lines: &Vec<Vec<String>>, widths: &mut Vec<usize>| {
for (j, cell_subs) in sub_lines.iter().enumerate() {
for s in cell_subs {
widths[j] = widths[j].max(visible_width(s));
}
}
};
measure(&header_cells, &mut widths);
for row in &body {
measure(row, &mut widths);
}
for w in widths.iter_mut() {
*w = (*w).max(1);
}
let border_style = Style::default().fg(Color::DarkGray);
let header_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let sep = Span::styled(" │ ", border_style);
out.push(Line::styled(
border_row("┌", "┬", "┐", &widths),
border_style,
));
render_logical_row(&header_cells, &widths, &sep, Some(header_style), out);
let mid_border = Line::styled(border_row("├", "┼", "┤", &widths), border_style);
out.push(mid_border.clone());
for (i, row) in body.iter().enumerate() {
if i > 0 {
out.push(mid_border.clone());
}
render_logical_row(row, &widths, &sep, None, out);
}
out.push(Line::styled(
border_row("└", "┴", "┘", &widths),
border_style,
));
Some(idx - start)
}
fn render_logical_row(
cells: &[Vec<String>],
widths: &[usize],
sep: &Span<'static>,
force_style: Option<Style>,
out: &mut Vec<Line<'static>>,
) {
let height = cells.iter().map(|c| c.len().max(1)).max().unwrap_or(1);
for vl in 0..height {
let row: Vec<String> = cells
.iter()
.map(|c| c.get(vl).cloned().unwrap_or_default())
.collect();
out.push(render_row(&row, widths, sep, force_style));
}
}
fn is_table_row(line: &str) -> bool {
line.starts_with('|') && line.len() > 1
}
fn is_table_separator(line: &str) -> bool {
if !line.starts_with('|') {
return false;
}
let cells = split_cells(line);
if cells.is_empty() {
return false;
}
cells.iter().all(|c| {
let trimmed = c.trim().trim_start_matches(':').trim_end_matches(':');
!trimmed.is_empty() && trimmed.chars().all(|ch| ch == '-')
})
}
fn split_cells(line: &str) -> Vec<String> {
let trimmed = line.trim().trim_start_matches('|').trim_end_matches('|');
trimmed.split('|').map(|c| c.trim().to_string()).collect()
}
fn split_br(text: &str) -> Vec<String> {
if !text.contains('<') && !text.contains('<') {
return vec![text.to_string()];
}
let chars: Vec<char> = text.chars().collect();
let mut out: Vec<String> = Vec::new();
let mut buf = String::new();
let mut i = 0;
while i < chars.len() {
if chars[i] == '<' {
let next1 = chars.get(i + 1).copied();
let next2 = chars.get(i + 2).copied();
if next1.is_some_and(|c| c.eq_ignore_ascii_case(&'b'))
&& next2.is_some_and(|c| c.eq_ignore_ascii_case(&'r'))
{
let mut j = i + 3;
while j < chars.len() && chars[j].is_ascii_whitespace() {
j += 1;
}
if j < chars.len() && chars[j] == '/' {
j += 1;
while j < chars.len() && chars[j].is_ascii_whitespace() {
j += 1;
}
}
if j < chars.len() && chars[j] == '>' {
out.push(std::mem::take(&mut buf));
i = j + 1;
continue;
}
}
}
buf.push(chars[i]);
i += 1;
}
out.push(buf);
out
}
fn visible_width(cell: &str) -> usize {
parse_inline(cell)
.iter()
.map(|s| s.content.chars().count())
.sum()
}
fn border_row(left: &str, mid: &str, right: &str, widths: &[usize]) -> String {
let mut s = String::new();
s.push_str(left);
for (j, w) in widths.iter().enumerate() {
for _ in 0..(w + 2) {
s.push('─');
}
if j + 1 < widths.len() {
s.push_str(mid);
}
}
s.push_str(right);
s
}
fn render_row(
cells: &[String],
widths: &[usize],
sep: &Span<'static>,
force_style: Option<Style>,
) -> Line<'static> {
let border_style = Style::default().fg(Color::DarkGray);
let mut spans: Vec<Span<'static>> = Vec::new();
spans.push(Span::styled("│ ", border_style));
for (j, cell) in cells.iter().enumerate() {
let mut cell_spans = parse_inline(cell);
if let Some(s) = force_style {
cell_spans = cell_spans
.into_iter()
.map(|span| {
let content = span.content.into_owned();
Span::styled(content, s)
})
.collect();
}
let cell_w: usize = cell_spans.iter().map(|s| s.content.chars().count()).sum();
spans.extend(cell_spans);
let pad = widths.get(j).copied().unwrap_or(0).saturating_sub(cell_w);
if pad > 0 {
spans.push(Span::raw(" ".repeat(pad)));
}
if j + 1 < cells.len() {
spans.push(sep.clone());
}
}
spans.push(Span::styled(" │", border_style));
Line::from(spans)
}
fn highlight_line(line: &str, trimmed: &str) -> Line<'static> {
if trimmed.starts_with('#') {
return Line::styled(
line.to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
}
if trimmed.starts_with('>') {
return Line::styled(
line.to_string(),
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::ITALIC),
);
}
if (trimmed.starts_with("---") || trimmed.starts_with("***") || trimmed.starts_with("___"))
&& trimmed.chars().all(|c| matches!(c, '-' | '*' | '_'))
{
return Line::styled(
line.to_string(),
Style::default().add_modifier(Modifier::DIM),
);
}
let (prefix_len, marker_color) = if let Some(rest) = trimmed.strip_prefix("- ") {
(line.len() - rest.len(), Some(Color::Yellow))
} else if let Some(rest) = trimmed.strip_prefix("* ") {
(line.len() - rest.len(), Some(Color::Yellow))
} else {
(0, None)
};
let leading_ws = &line[..line.len() - trimmed.len()];
let mut spans: Vec<Span<'static>> = Vec::new();
if prefix_len > 0 {
spans.push(Span::raw(leading_ws.to_string()));
let marker = &line[leading_ws.len()..prefix_len];
spans.push(Span::styled(
marker.to_string(),
Style::default()
.fg(marker_color.unwrap_or(Color::Yellow))
.add_modifier(Modifier::BOLD),
));
spans.extend(parse_inline(&line[prefix_len..]));
} else {
spans.extend(parse_inline(line));
}
Line::from(spans)
}
fn parse_inline(text: &str) -> Vec<Span<'static>> {
let chars: Vec<char> = text.chars().collect();
let mut spans: Vec<Span<'static>> = Vec::new();
let mut buf = String::new();
let flush = |buf: &mut String, spans: &mut Vec<Span<'static>>| {
if !buf.is_empty() {
spans.push(Span::raw(std::mem::take(buf)));
}
};
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '`'
&& let Some(end) = chars[i + 1..].iter().position(|&c| c == '`')
{
flush(&mut buf, &mut spans);
let code: String = chars[i + 1..i + 1 + end].iter().collect();
spans.push(Span::styled(code, Style::default().fg(Color::Green)));
i += end + 2;
continue;
}
if c == '*' && chars.get(i + 1) == Some(&'*') {
let search_start = i + 2;
let mut j = search_start;
while j + 1 < chars.len() {
if chars[j] == '*' && chars[j + 1] == '*' {
break;
}
j += 1;
}
if j + 1 < chars.len() && chars[j] == '*' && chars[j + 1] == '*' {
flush(&mut buf, &mut spans);
let inner: String = chars[search_start..j].iter().collect();
spans.push(Span::styled(
inner,
Style::default().add_modifier(Modifier::BOLD),
));
i = j + 2;
continue;
}
}
if c == '*' && chars.get(i + 1) != Some(&'*') {
let search_start = i + 1;
let mut j = search_start;
while j < chars.len() && chars[j] != '*' {
j += 1;
}
if j < chars.len() && j > search_start {
flush(&mut buf, &mut spans);
let inner: String = chars[search_start..j].iter().collect();
spans.push(Span::styled(
inner,
Style::default().add_modifier(Modifier::ITALIC),
));
i = j + 1;
continue;
}
}
buf.push(c);
i += 1;
}
flush(&mut buf, &mut spans);
spans
}
#[cfg(test)]
mod tests {
use super::*;
fn span_texts(spans: &[Span<'static>]) -> Vec<String> {
spans.iter().map(|s| s.content.to_string()).collect()
}
#[test]
fn parse_inline_plain_text_one_span() {
let s = parse_inline("just text");
assert_eq!(span_texts(&s), vec!["just text"]);
}
#[test]
fn parse_inline_extracts_inline_code() {
let s = parse_inline("run `cargo build` then go");
assert_eq!(span_texts(&s), vec!["run ", "cargo build", " then go"]);
}
#[test]
fn parse_inline_extracts_bold() {
let s = parse_inline("hello **world** here");
assert_eq!(span_texts(&s), vec!["hello ", "world", " here"]);
}
#[test]
fn parse_inline_extracts_italic() {
let s = parse_inline("a *cool* thing");
assert_eq!(span_texts(&s), vec!["a ", "cool", " thing"]);
}
#[test]
fn parse_inline_unclosed_delimiter_falls_through() {
let s = parse_inline("a `b c");
assert_eq!(span_texts(&s), vec!["a `b c"]);
}
#[test]
fn parse_inline_does_not_treat_double_star_as_italic() {
let s = parse_inline("**x**");
assert_eq!(span_texts(&s), vec!["x"]);
}
#[test]
fn highlight_tracks_code_block_state() {
let md = "regular\n```\nin block\n```\nafter";
let lines = highlight(md);
assert_eq!(lines.len(), 5);
let in_block = &lines[2];
assert!(!in_block.spans.is_empty());
}
#[test]
fn detects_simple_table() {
let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
let lines = highlight(md);
assert_eq!(lines.len(), 5);
let top: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
let bot: String = lines[4].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(top.starts_with('┌') && top.ends_with('┐'));
assert!(bot.starts_with('└') && bot.ends_with('┘'));
}
#[test]
fn table_pads_columns_to_widest_cell() {
let md = "| a | longvalue |\n|---|---|\n| longerleft | x |\n";
let lines = highlight(md);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>()
})
.collect();
let first = widths[0];
assert!(widths.iter().all(|&w| w == first), "widths: {widths:?}");
}
#[test]
fn non_table_pipes_pass_through_unchanged() {
let md = "| this is just a sentence with a pipe |\nno separator below";
let lines = highlight(md);
assert_eq!(lines.len(), 2);
let first: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(first.contains('|'));
}
#[test]
fn split_br_handles_common_variants() {
assert_eq!(split_br("a<br>b"), vec!["a", "b"]);
assert_eq!(split_br("a<br/>b"), vec!["a", "b"]);
assert_eq!(split_br("a<br />b"), vec!["a", "b"]);
assert_eq!(split_br("a<BR>b"), vec!["a", "b"]);
assert_eq!(split_br("no breaks"), vec!["no breaks"]);
assert_eq!(split_br("end<br>"), vec!["end", ""]);
assert_eq!(split_br("<b>not a br</b>"), vec!["<b>not a br</b>"]);
}
#[test]
fn body_line_with_br_emits_multiple_visual_lines() {
let md = "first<br>second<br>third";
let lines = highlight(md);
assert_eq!(lines.len(), 3);
let text: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert_eq!(text, vec!["first", "second", "third"]);
}
#[test]
fn table_cell_br_expands_row_height() {
let md = "| Name | Stats |\n|---|---|\n| Goblin | HP: 7<br>AC: 15<br>STR: 8 |\n";
let lines = highlight(md);
assert_eq!(lines.len(), 7);
let body_widths: Vec<usize> = lines[3..6]
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.chars().count())
.sum::<usize>()
})
.collect();
let first = body_widths[0];
assert!(
body_widths.iter().all(|&w| w == first),
"body widths: {body_widths:?}"
);
let row2_text: String = lines[4].spans.iter().map(|s| s.content.as_ref()).collect();
let row3_text: String = lines[5].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(row2_text.contains("AC: 15"));
assert!(row3_text.contains("STR: 8"));
}
#[test]
fn body_rows_get_horizontal_separators_between_them() {
let md = "| a | b |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n| 5 | 6 |\n";
let lines = highlight(md);
assert_eq!(lines.len(), 9);
for i in [2usize, 4, 6] {
let t: String = lines[i].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(t.starts_with('├') && t.ends_with('┤'), "row {i}: {t:?}");
}
}
#[test]
fn json_highlight_pretty_prints_valid_input() {
let lines = highlight_json(r#"{"name":"Goblin","hp":7,"alive":true}"#);
assert!(lines.len() >= 3, "expected multi-line pretty output");
}
#[test]
fn json_highlight_keys_and_strings_get_different_styles() {
let line = highlight_json_line(r#" "name": "Goblin","#);
let styled: Vec<_> = line
.spans
.iter()
.filter(|s| s.content.starts_with('"'))
.collect();
assert_eq!(styled.len(), 2);
assert_ne!(styled[0].style.fg, styled[1].style.fg);
}
#[test]
fn json_highlight_invalid_input_falls_back_to_raw() {
let lines = highlight_json("not { valid json");
assert!(lines.len() >= 2);
let first: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
assert!(first.contains("invalid JSON"));
}
#[test]
fn table_inside_code_block_is_not_parsed() {
let md = "```\n| a | b |\n|---|---|\n| 1 | 2 |\n```\n";
let lines = highlight(md);
for l in &lines {
let text: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
!text.contains('┌') && !text.contains('└'),
"unexpected box-drawing in {text:?}"
);
}
}
}