use crate::{
common::conversation::{Block, Inline, ListItem, ListItemMarker, TableRow},
ui::style::{
DARK_GRAY2, DARK_GRAY3, GUTTER, GUTTER_WIDTH, HighlightConfig, METALLIC_BLUE, NEAR_BLACK,
SyntectStyle, TextStyle,
},
};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
use std::{cmp::max, iter};
use syntect::{easy::HighlightLines, highlighting::FontStyle};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedTableCell {
content: Vec<Line<'static>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedTableRow {
content: Vec<RenderedTableCell>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedTable {
content: Vec<RenderedTableRow>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InlineFragment {
Text(Span<'static>),
LineBreak,
}
#[derive(Debug, Clone, Default)]
pub struct RenderConfig {
pub highlight_cfg: HighlightConfig,
pub style: TextStyle,
pub width: u16,
}
impl RenderConfig {
pub fn new() -> Self {
RenderConfig {
highlight_cfg: HighlightConfig::new(),
style: TextStyle::default(),
width: 0,
}
}
pub fn with_style(mut self, style: TextStyle) -> Self {
self.style = style;
self
}
}
pub fn render_block(
block: &Block,
config: &RenderConfig,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
match block {
Block::Code {
lang,
content,
copy_id,
} => lines.extend(render_code(content, lang, *copy_id, config)),
Block::Heading { depth, content } => lines.extend(render_heading(content, *depth, config)),
Block::List { content } => lines.extend(render_list(content, config)),
Block::Paragraph(inlines) => lines.extend(render_paragraph(inlines, config)),
Block::Quote(blocks) => lines.extend(render_quote(blocks, config)),
Block::Table { content } => lines.extend(render_table(content, config)),
Block::ThematicBreak => {
let delim_style = TextStyle {
fg_color: DARK_GRAY2,
..config.style
};
lines.push(Line::styled("────────────────", delim_style))
}
Block::Unsupported(s) => {
let warning_style = TextStyle {
fg_color: Color::Red,
..config.style
};
lines.push(Line::from(Span::styled(
format!("Warning: Unsupported block in markdown: {s}"),
warning_style,
)))
}
}
lines.push(Line::raw(" "));
lines
}
pub fn render_inline(inline: &Inline, config: &RenderConfig) -> Vec<InlineFragment> {
let mut fragments = Vec::new();
match inline {
Inline::Code(code) => {
let mut s = config.style.clone();
s.bg_color = NEAR_BLACK;
fragments.push(InlineFragment::Text(Span::styled(code.to_owned(), s)))
}
Inline::Emphasis(inlines) => {
let mut config = config.clone();
config.style.italic = true;
fragments.extend(
inlines
.iter()
.flat_map(|inline| render_inline(inline, &config))
.collect::<Vec<InlineFragment>>(),
)
}
Inline::StrongEmphasis(inlines) => {
let mut config = config.clone();
config.style.bold = true;
fragments.extend(
inlines
.iter()
.flat_map(|inline| render_inline(inline, &config))
.collect::<Vec<InlineFragment>>(),
)
}
Inline::Link { url, title, label } => fragments.push(InlineFragment::Text(Span::styled(
url.to_owned(),
Style::from(config.style.clone()),
))),
Inline::Image(_) => {}
Inline::LineBreak => fragments.push(InlineFragment::LineBreak),
Inline::Text(text) => fragments.push(InlineFragment::Text(Span::styled(
text.to_owned(),
Style::from(config.style.clone()),
))),
Inline::Unsupported(_) => {
}
}
fragments
}
pub fn render_heading(content: &[Inline], depth: u8, config: &RenderConfig) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let marker = match depth {
0 => "█ ",
1 => "▊ ",
2 => "▌ ",
_ => "▎ ",
};
let mut fragments = Vec::new();
let mut new_config = config.clone();
new_config.style.bold = true;
new_config.style.fg_color = METALLIC_BLUE;
for inline in content {
fragments.extend(render_inline(inline, &new_config));
}
let mut content_lines = wrap_lines(fragments_to_lines(&fragments), config.width);
if let Some(first) = content_lines.get_mut(0) {
first
.spans
.insert(0, Span::styled(marker, new_config.style.clone()));
}
for line in content_lines.iter_mut().skip(1) {
line.spans.insert(
0,
Span::styled(" ".repeat(marker.width()), new_config.style.clone()),
);
}
if let Some(last) = content_lines.last_mut() {
let delim_style = TextStyle {
fg_color: DARK_GRAY2,
..config.style
};
last.push_span(Span::styled(
" ".to_owned() + &"─".repeat(7),
delim_style,
));
}
lines.extend(content_lines);
lines
}
pub fn render_list(content: &[ListItem], config: &RenderConfig) -> Vec<Line<'static>> {
let marker_indend = content.iter().fold(2, |current, item| {
let candidate = match item.marker {
ListItemMarker::Bullet => 2, ListItemMarker::Checkbox(_) => 2, ListItemMarker::Number(index) => index.to_string().len() + 2,
};
max(current, candidate)
});
content
.iter()
.flat_map(|item| render_list_item(item, marker_indend, config))
.collect()
}
pub fn render_list_item(
item: &ListItem,
marker_indend: usize,
config: &RenderConfig,
) -> Vec<Line<'static>> {
let config = RenderConfig {
highlight_cfg: config.highlight_cfg.clone(),
style: config.style.clone(),
width: config.width.saturating_sub(marker_indend as u16),
};
let mut lines: Vec<Line<'static>> = item
.content
.iter()
.flat_map(|block| render_block(block, &config))
.collect();
if let Some(first_line) = lines.first_mut() {
let marker = match &item.marker {
ListItemMarker::Bullet => {
let marker = "• ".to_string();
Span::styled(
format!(
"{}{}",
marker,
" ".repeat(marker_indend.saturating_sub(marker.width()))
),
config.style.clone(),
)
}
ListItemMarker::Checkbox(checked) => {
let marker = (if *checked { "☑ " } else { "☐ " }).to_string();
Span::styled(
format!(
"{}{}",
marker,
" ".repeat(marker_indend.saturating_sub(marker.width()))
),
config.style.clone(),
)
}
ListItemMarker::Number(n) => Span::styled(
format!(
"{}. {}",
n,
" ".repeat(marker_indend.saturating_sub(*n as usize + 1))
),
config.style.clone(),
),
};
first_line.spans.insert(0, marker);
}
for line in &mut lines.iter_mut().skip(1) {
line.spans.insert(
0,
Span::styled(" ".repeat(marker_indend), config.style.clone()),
);
}
lines
}
pub fn render_paragraph(inlines: &[Inline], config: &RenderConfig) -> Vec<Line<'static>> {
let mut lines = fragments_to_lines(
&inlines
.iter()
.flat_map(|inline| render_inline(inline, config))
.collect::<Vec<InlineFragment>>(),
);
lines = wrap_lines(lines, config.width);
lines
}
pub fn render_quote(content: &[Block], config: &RenderConfig) -> Vec<Line<'static>> {
let config = RenderConfig {
highlight_cfg: config.highlight_cfg.clone(),
style: config.style.clone(),
width: config.width.saturating_sub(*GUTTER_WIDTH),
};
let lines = content
.iter()
.flat_map(|block| render_block(block, &config))
.collect();
add_gutter(lines, DARK_GRAY2, None)
}
pub fn render_table(content: &[TableRow], config: &RenderConfig) -> Vec<Line<'static>> {
let separator_prefill = 1;
let separator_postfill = 1;
let separator = format!(
"{}│{}",
" ".repeat(separator_prefill),
" ".repeat(separator_postfill)
);
let indend = " ";
let num_columns = content
.iter()
.fold(0, |count, row| max(count, row.content.len()));
let rows: Vec<_> = content
.iter()
.map(|row| {
let mut cells: Vec<_> = row
.content
.iter()
.map(|cell| {
let fragments = cell
.content
.iter()
.flat_map(|inline| render_inline(inline, config))
.collect::<Vec<_>>();
RenderedTableCell {
content: fragments_to_lines(&fragments),
}
})
.collect();
while cells.len() < num_columns {
cells.push(RenderedTableCell {
content: vec![Line::raw("")],
});
}
RenderedTableRow { content: cells }
})
.collect();
let mut prerendered_table = RenderedTable { content: rows };
let mut max_column_widths = vec![0; num_columns];
for row in prerendered_table.content.iter() {
for (idx, cell) in row.content.iter().enumerate() {
let content_width = cell.content.iter().fold(0, |w, s| max(w, s.width()));
max_column_widths[idx] = max(content_width, max_column_widths[idx]);
}
}
let mut max_row_height = vec![0; prerendered_table.content.len()];
for (idx, row) in prerendered_table.content.iter().enumerate() {
for cell in row.content.iter() {
let content_height = cell.content.len();
if content_height > max_row_height[idx] {
max_row_height[idx] = content_height;
}
}
}
for (idx, row) in prerendered_table.content.iter_mut().enumerate() {
for cell in row.content.iter_mut() {
while cell.content.len() < max_row_height[idx] {
cell.content.push(Line::raw(""));
}
}
}
let mut lines = Vec::new();
if let Some(header) = prerendered_table.content.first() {
lines.extend(render_table_row(
&header.content,
&max_column_widths,
indend,
&separator,
config,
));
}
let mut spans = Vec::new();
if let Some(column) = max_column_widths.first() {
spans.push(Span::styled(
format!("{}{}", indend, "─".repeat(*column)),
config.style.clone(),
));
}
spans.extend(max_column_widths.iter().skip(1).map(|l| {
Span::styled(
format!(
"{}┼{}",
"─".repeat(separator_prefill),
"─".repeat(l + separator_postfill)
),
config.style.clone(),
)
}));
lines.push(Line::from(spans));
lines.extend(prerendered_table.content.iter().skip(1).flat_map(|row| {
render_table_row(&row.content, &max_column_widths, indend, &separator, config)
}));
lines
}
pub fn render_table_row(
cells: &[RenderedTableCell],
column_widths: &[usize],
prefix: &str,
infix: &str,
config: &RenderConfig,
) -> Vec<Line<'static>> {
let mut sep = prefix;
if let Some(cell) = cells.first() {
let len = cell.content.len();
(0..len)
.map(|i| {
Line::from(
cells
.iter()
.enumerate()
.flat_map(|(j, cell)| {
let mut line =
vec![Span::styled(sep.to_string(), config.style.clone())];
sep = infix;
let content_width =
cell.content[i].spans.iter().fold(0, |l, s| l + s.width());
let num_fill_chars = column_widths
.get(j)
.unwrap_or(&0)
.saturating_sub(content_width);
line.extend(cell.content[i].spans.clone());
line.push(Span::raw(" ".repeat(num_fill_chars)));
line
})
.collect::<Vec<_>>(),
)
})
.collect()
} else {
Vec::new()
}
}
pub fn render_code(
content: &str,
lang: &str,
copy_id: u32,
config: &RenderConfig,
) -> Vec<Line<'static>> {
let code_style_config = RenderConfig {
style: TextStyle {
bg_color: DARK_GRAY3,
..config.style.clone()
},
..config.clone()
};
let mut header_style = config.style.clone();
header_style.bg_color = DARK_GRAY3;
header_style.italic = true;
let mut content_lines = highlight_code(content, lang, &code_style_config); let mut max_width = content_lines
.iter()
.fold(0, |max_width, line| std::cmp::max(max_width, line.width()));
max_width += 1; max_width = std::cmp::min(max_width, config.width as usize);
for line in content_lines.iter_mut() {
line.push_span(Span::styled(
" ".repeat(max_width.saturating_sub(line.width())),
code_style_config.style.clone(),
))
}
let lang_marker = format!(" {}", lang);
let copy_marker = format!(" {} ", copy_id);
let header = iter::once(Line::from(Span::styled(
format!(
"{}{}{}",
lang_marker,
" ".repeat(max_width.saturating_sub(lang_marker.width() + copy_marker.width())),
copy_marker
),
Style::from(header_style),
)));
let lines = header.chain(content_lines).collect();
add_gutter(lines, DARK_GRAY2, Some(DARK_GRAY3))
}
pub fn add_gutter(
lines: Vec<Line<'static>>,
color: Color,
bg_color: Option<Color>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|line| {
let mut style = Style::default().fg(color);
style.bg = bg_color;
let mut spans = Vec::with_capacity(line.spans.len() + 1);
spans.push(Span::styled(GUTTER, style));
spans.extend(line.spans);
Line::from(spans)
})
.collect()
}
fn highlight_code(code: &str, lang: &str, config: &RenderConfig) -> Vec<Line<'static>> {
let syntax = config
.highlight_cfg
.syntax_set
.find_syntax_by_token(lang)
.unwrap_or_else(|| config.highlight_cfg.syntax_set.find_syntax_plain_text());
let mut highlighter = HighlightLines::new(syntax, &config.highlight_cfg.theme);
code.lines()
.map(|line| {
let ranges = highlighter
.highlight_line(line, &config.highlight_cfg.syntax_set)
.unwrap();
Line::from(ranges_to_spans(ranges, config.style.clone()))
})
.collect()
}
fn ranges_to_spans(ranges: Vec<(SyntectStyle, &str)>, base_style: TextStyle) -> Vec<Span<'static>> {
ranges
.into_iter()
.map(|(style, text)| {
Span::styled(
text.to_string(),
merge_syntect_style(style, base_style.clone()),
)
})
.collect()
}
fn merge_syntect_style(style: SyntectStyle, base_style: TextStyle) -> Style {
let mut result = Style::default()
.fg(Color::Rgb(
style.foreground.r,
style.foreground.g,
style.foreground.b,
))
.bg(base_style.bg_color);
if style.font_style.contains(FontStyle::BOLD) {
result = result.add_modifier(Modifier::BOLD);
}
if style.font_style.contains(FontStyle::ITALIC) {
result = result.add_modifier(Modifier::ITALIC);
}
if style.font_style.contains(FontStyle::UNDERLINE) {
result = result.add_modifier(Modifier::UNDERLINED);
}
result
}
pub fn fragments_to_lines(fragments: &[InlineFragment]) -> Vec<Line<'static>> {
fragments
.split(|fragment| *fragment == InlineFragment::LineBreak)
.map(|slice| {
slice
.iter()
.filter_map(|f| match f {
InlineFragment::Text(span) => Some(span.to_owned()),
InlineFragment::LineBreak => None, })
.collect::<Vec<Span<'static>>>()
})
.map(Line::from)
.collect()
}
pub fn wrap_lines(lines: Vec<Line<'static>>, width: u16) -> Vec<Line<'static>> {
lines
.into_iter()
.flat_map(|line| wrap_line(line, width, 0))
.collect()
}
pub fn wrap_line(line: Line<'static>, width: u16, position: usize) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let mut current_line = Vec::new();
let mut position = position;
current_line.clear();
for span in line.spans {
let mut current_span = Some(span);
while let Some(s) = current_span {
match wrap_span(s, width, position) {
(Some(span), None) => {
let increment = span.content.width();
current_line.push(span);
position += increment;
current_span = None; }
(Some(span1), Some(span2)) => {
current_line.push(span1);
lines.push(Line::from(current_line.clone()));
current_line.clear();
position = 0;
current_span = Some(span2);
}
(None, Some(span)) => {
lines.push(Line::from(current_line.clone()));
current_line.clear();
position = 0;
current_span = Some(span);
}
(None, None) => {
current_span = None;
}
}
}
}
if !current_line.is_empty() {
lines.push(Line::from(current_line));
}
lines
}
fn wrap_span(
span: Span<'static>,
width: u16,
position: usize,
) -> (Option<Span<'static>>, Option<Span<'static>>) {
let width: usize = width.into();
let style = span.style;
let mut pos = position;
let mut first = Vec::new();
let mut second = Vec::new();
for chunk in span.content.split_inclusive(char::is_whitespace) {
let w = UnicodeWidthStr::width(chunk);
pos += w;
if pos <= width {
first.push(chunk);
} else {
second.push(chunk);
}
}
if let Some(w) = second.first()
&& UnicodeWidthStr::width(*w) >= width
{
let first_width = first.iter().fold(0, |count, text| count + text.width());
let target_display_width = width.saturating_sub(first_width);
let mut current_width = 0;
let mut split_char_index = 0;
for (byte_idx, ch) in w.char_indices() {
let ch_width = ch.width().unwrap_or_default();
if current_width + ch_width > target_display_width {
break;
}
current_width += ch_width;
split_char_index = byte_idx + ch.len_utf8();
}
let (w1, w2) = w.split_at(split_char_index);
first.push(w1);
second.insert(0, w2);
}
let res1 = if first.is_empty() {
None
} else {
Some(Span::from(first.join("")).style(style))
};
let res2 = if second.is_empty() {
None
} else {
Some(Span::from(second.join("")).style(style))
};
(res1, res2)
}