feat(render): render markdown tables with comfy-table
This commit is contained in:
+201
-1
@@ -2,7 +2,7 @@ use crate::utils::decode_bin;
|
|||||||
|
|
||||||
use ansi_colours::AsRGB;
|
use ansi_colours::AsRGB;
|
||||||
use anyhow::{Context, Result, anyhow};
|
use anyhow::{Context, Result, anyhow};
|
||||||
use comfy_table::CellAlignment;
|
use comfy_table::{CellAlignment, ContentArrangement, Table, presets::UTF8_FULL};
|
||||||
use crossterm::style::{Color, Stylize};
|
use crossterm::style::{Color, Stylize};
|
||||||
use crossterm::terminal;
|
use crossterm::terminal;
|
||||||
use fancy_regex::Regex;
|
use fancy_regex::Regex;
|
||||||
@@ -242,6 +242,38 @@ fn render_hrule(styles: &MarkdownStyles) -> String {
|
|||||||
"────────".with(styles.hrule).to_string()
|
"────────".with(styles.hrule).to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn colorize_box_chars(text: &str, color: Color) -> String {
|
||||||
|
let sample = "X".with(color).to_string();
|
||||||
|
let paint_idx = match sample.find('X') {
|
||||||
|
Some(i) => i,
|
||||||
|
None => return text.to_string(),
|
||||||
|
};
|
||||||
|
let prefix = &sample[..paint_idx];
|
||||||
|
let suffix = &sample[paint_idx + 1..];
|
||||||
|
if prefix.is_empty() && suffix.is_empty() {
|
||||||
|
return text.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = String::with_capacity(text.len() + text.len() / 4);
|
||||||
|
let mut in_border = false;
|
||||||
|
for c in text.chars() {
|
||||||
|
let is_border = matches!(c, '\u{2500}'..='\u{257F}');
|
||||||
|
if is_border && !in_border {
|
||||||
|
out.push_str(prefix);
|
||||||
|
in_border = true;
|
||||||
|
} else if !is_border && in_border {
|
||||||
|
out.push_str(suffix);
|
||||||
|
in_border = false;
|
||||||
|
}
|
||||||
|
out.push(c);
|
||||||
|
}
|
||||||
|
if in_border {
|
||||||
|
out.push_str(suffix);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
fn apply_inline(text: &str, styles: &MarkdownStyles) -> String {
|
fn apply_inline(text: &str, styles: &MarkdownStyles) -> String {
|
||||||
let mut code_bank: Vec<String> = Vec::new();
|
let mut code_bank: Vec<String> = Vec::new();
|
||||||
let masked = regex_replace(text, &INLINE_CODE_RE, |caps| {
|
let masked = regex_replace(text, &INLINE_CODE_RE, |caps| {
|
||||||
@@ -303,6 +335,16 @@ fn apply_inline(text: &str, styles: &MarkdownStyles) -> String {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
enum TableState {
|
||||||
|
PendingHeader(String),
|
||||||
|
Active {
|
||||||
|
header: Vec<String>,
|
||||||
|
alignments: Vec<CellAlignment>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MarkdownRender {
|
pub struct MarkdownRender {
|
||||||
options: RenderOptions,
|
options: RenderOptions,
|
||||||
syntax_set: SyntaxSet,
|
syntax_set: SyntaxSet,
|
||||||
@@ -312,6 +354,8 @@ pub struct MarkdownRender {
|
|||||||
prev_line_type: LineType,
|
prev_line_type: LineType,
|
||||||
wrap_width: Option<u16>,
|
wrap_width: Option<u16>,
|
||||||
styles: MarkdownStyles,
|
styles: MarkdownStyles,
|
||||||
|
#[allow(dead_code)]
|
||||||
|
table_state: Option<TableState>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MarkdownRender {
|
impl MarkdownRender {
|
||||||
@@ -350,6 +394,7 @@ impl MarkdownRender {
|
|||||||
prev_line_type: line_type,
|
prev_line_type: line_type,
|
||||||
wrap_width,
|
wrap_width,
|
||||||
styles,
|
styles,
|
||||||
|
table_state: None,
|
||||||
options,
|
options,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -391,6 +436,49 @@ impl MarkdownRender {
|
|||||||
self.wrap_line(styled, false)
|
self.wrap_line(styled, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn render_table(
|
||||||
|
&self,
|
||||||
|
header: Vec<String>,
|
||||||
|
alignments: Vec<CellAlignment>,
|
||||||
|
rows: Vec<Vec<String>>,
|
||||||
|
) -> String {
|
||||||
|
let mut table = Table::new();
|
||||||
|
table.load_preset(UTF8_FULL);
|
||||||
|
table.set_content_arrangement(ContentArrangement::Dynamic);
|
||||||
|
if let Some(width) = self.wrap_width {
|
||||||
|
table.set_width(width);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (heading_color, _) = self.styles.heading;
|
||||||
|
let styled_header: Vec<String> = header
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
apply_inline(c, &self.styles)
|
||||||
|
.with(heading_color)
|
||||||
|
.bold()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
table.set_header(styled_header);
|
||||||
|
|
||||||
|
for (i, align) in alignments.iter().enumerate() {
|
||||||
|
if let Some(col) = table.column_mut(i) {
|
||||||
|
col.set_cell_alignment(*align);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for row in rows {
|
||||||
|
let styled_row: Vec<String> = row
|
||||||
|
.iter()
|
||||||
|
.map(|c| apply_inline(c, &self.styles))
|
||||||
|
.collect();
|
||||||
|
table.add_row(styled_row);
|
||||||
|
}
|
||||||
|
|
||||||
|
colorize_box_chars(&table.to_string(), self.styles.table_border)
|
||||||
|
}
|
||||||
|
|
||||||
fn check_line(
|
fn check_line(
|
||||||
&self,
|
&self,
|
||||||
line: &str,
|
line: &str,
|
||||||
@@ -1164,6 +1252,118 @@ std::error::Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_box_chars_wraps_border_runs() {
|
||||||
|
let input = "┌─┐\nabc\n└─┘";
|
||||||
|
let output = colorize_box_chars(input, Color::Red);
|
||||||
|
assert!(output.starts_with("\x1b["), "border run starts with SGR: {output:?}");
|
||||||
|
assert!(output.contains("abc"), "non-border content preserved");
|
||||||
|
assert!(output.contains("┌"));
|
||||||
|
assert!(output.contains("└"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn colorize_box_chars_leaves_reset_color_untouched() {
|
||||||
|
let output = colorize_box_chars("no borders here", Color::Red);
|
||||||
|
assert_eq!(output, "no borders here");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_table_renders_headers_rows_and_borders() {
|
||||||
|
let options = RenderOptions::default();
|
||||||
|
let render = MarkdownRender::init(options).unwrap();
|
||||||
|
let header = vec!["A".into(), "B".into(), "C".into()];
|
||||||
|
let alignments = vec![
|
||||||
|
CellAlignment::Left,
|
||||||
|
CellAlignment::Left,
|
||||||
|
CellAlignment::Left,
|
||||||
|
];
|
||||||
|
let rows = vec![
|
||||||
|
vec!["1".into(), "2".into(), "3".into()],
|
||||||
|
vec!["4".into(), "5".into(), "6".into()],
|
||||||
|
];
|
||||||
|
let output = render.render_table(header, alignments, rows);
|
||||||
|
for expected in ["A", "B", "C", "1", "2", "3", "4", "5", "6"] {
|
||||||
|
assert!(output.contains(expected), "cell {expected:?} in output: {output:?}");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
output
|
||||||
|
.chars()
|
||||||
|
.any(|c| matches!(c, '\u{2500}'..='\u{257F}')),
|
||||||
|
"table has box-drawing chars: {output:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_table_header_uses_bold() {
|
||||||
|
let options = RenderOptions::default();
|
||||||
|
let render = MarkdownRender::init(options).unwrap();
|
||||||
|
let header = vec!["Header".into()];
|
||||||
|
let alignments = vec![CellAlignment::Left];
|
||||||
|
let output = render.render_table(header, alignments, vec![]);
|
||||||
|
assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}");
|
||||||
|
assert!(output.contains("Header"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_table_applies_inline_markdown_to_cells() {
|
||||||
|
let options = RenderOptions::default();
|
||||||
|
let render = MarkdownRender::init(options).unwrap();
|
||||||
|
let header = vec!["H".into()];
|
||||||
|
let alignments = vec![CellAlignment::Left];
|
||||||
|
let rows = vec![vec!["**bold**".into()]];
|
||||||
|
let output = render.render_table(header, alignments, rows);
|
||||||
|
assert!(
|
||||||
|
!output.contains("**bold**"),
|
||||||
|
"asterisks stripped from cell: {output:?}",
|
||||||
|
);
|
||||||
|
assert!(output.contains("bold"));
|
||||||
|
assert!(output.contains("\x1b[1m"), "bold SGR present: {output:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_table_respects_alignment_specifiers() {
|
||||||
|
let options = RenderOptions::default();
|
||||||
|
let render = MarkdownRender::init(options).unwrap();
|
||||||
|
let header = vec!["L".into(), "R".into(), "C".into()];
|
||||||
|
let alignments = vec![
|
||||||
|
CellAlignment::Left,
|
||||||
|
CellAlignment::Right,
|
||||||
|
CellAlignment::Center,
|
||||||
|
];
|
||||||
|
let rows = vec![vec!["a".into(), "b".into(), "c".into()]];
|
||||||
|
let output = render.render_table(header, alignments, rows);
|
||||||
|
for expected in ["L", "R", "C", "a", "b", "c"] {
|
||||||
|
assert!(output.contains(expected), "cell {expected:?} present");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_table_handles_wide_chars_and_emoji() {
|
||||||
|
let options = RenderOptions::default();
|
||||||
|
let render = MarkdownRender::init(options).unwrap();
|
||||||
|
let header = vec!["Name".into()];
|
||||||
|
let alignments = vec![CellAlignment::Left];
|
||||||
|
let rows = vec![vec!["🎉".into()], vec!["日本".into()]];
|
||||||
|
let output = render.render_table(header, alignments, rows);
|
||||||
|
assert!(output.contains("🎉"));
|
||||||
|
assert!(output.contains("日本"));
|
||||||
|
assert!(output.lines().count() > 4, "multi-line output: {output:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_table_borders_wrapped_in_border_color() {
|
||||||
|
let options = RenderOptions::default();
|
||||||
|
let render = MarkdownRender::init(options).unwrap();
|
||||||
|
let header = vec!["a".into()];
|
||||||
|
let alignments = vec![CellAlignment::Left];
|
||||||
|
let output = render.render_table(header, alignments, vec![vec!["b".into()]]);
|
||||||
|
assert!(
|
||||||
|
output.starts_with("\x1b["),
|
||||||
|
"output starts with border color SGR: {output:?}",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn test_styles() -> MarkdownStyles {
|
fn test_styles() -> MarkdownStyles {
|
||||||
MarkdownStyles {
|
MarkdownStyles {
|
||||||
heading: (Color::Yellow, true),
|
heading: (Color::Yellow, true),
|
||||||
|
|||||||
Reference in New Issue
Block a user