use crate::{
common::conversation::{Block, Inline, ListItem, ListItemMarker, TableRow},
config::Symbols,
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 symbols: Symbols,
pub width: u16,
}
impl RenderConfig {
pub fn new() -> Self {
RenderConfig {
highlight_cfg: HighlightConfig::new(),
style: TextStyle::default(),
symbols: Symbols::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, None);
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(),
symbols: config.symbols.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, None);
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(),
symbols: config.symbols.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 mut prerendered_table = prerender_table(content, config);
let num_columns = content
.iter()
.fold(0, |count, row| max(count, row.content.len()));
let prefered_column_widths = prefered_column_width(&prerendered_table, num_columns);
render_table_when_within_constraints(
&mut prerendered_table,
prefered_column_widths.clone(),
&prefered_column_widths,
&separator,
separator_prefill,
config,
)
.or_else(|| {
let min_column_widths =
min_column_width(&prerendered_table, num_columns, char::is_whitespace);
render_table_when_within_constraints(
&mut prerendered_table,
min_column_widths,
&prefered_column_widths,
&separator,
separator_prefill,
config,
)
})
.or_else(|| {
let min_column_widths = min_column_width(&prerendered_table, num_columns, |c| {
!char::is_alphanumeric(c)
});
render_table_when_within_constraints(
&mut prerendered_table,
min_column_widths,
&prefered_column_widths,
&separator,
separator_prefill,
config,
)
})
.unwrap_or_else(|| {
tracing::debug!("Rendering table breaking at fixed positions.");
let column_widths =
column_widths_by_ratio(&prefered_column_widths, &separator, config.width.into());
render_table_with_constraints(
&mut prerendered_table,
&column_widths,
&separator,
separator_prefill,
config,
)
})
}
fn column_widths_by_ratio(
prefered_column_widths: &[usize],
separator: &str,
max_width: usize,
) -> Vec<usize> {
let available_width = max_width
.saturating_sub(separator.width() * prefered_column_widths.len().saturating_sub(1))
as f64;
let total_requested_width = prefered_column_widths.iter().max().unwrap_or(&0).to_owned() as f64;
let mut column_widths: Vec<usize> = prefered_column_widths
.iter()
.map(|w| (*w as f64) / total_requested_width * available_width)
.map(|w| w.round() as usize)
.collect();
while required_row_width(&column_widths, separator) > max_width {
let max_width = column_widths
.iter_mut()
.max()
.expect("min_column_width should not be empty after check at start of function");
*max_width -= 1;
}
column_widths
}
fn enlarge_columns(
min_column_widths: &mut [usize],
prefered_column_widths: &[usize],
infix: &str,
max_width: usize,
) {
if min_column_widths.is_empty() {
return;
}
let remaining_width = max_width.saturating_sub(required_row_width(min_column_widths, infix));
if remaining_width == 0 {
return;
}
let delta_widths: Vec<f64> = prefered_column_widths
.iter()
.zip(min_column_widths.iter())
.map(|(prefered, min)| prefered.saturating_sub(*min) as f64)
.collect();
let total_delta: f64 = delta_widths.iter().sum();
if total_delta == 0.0 {
return;
}
let added_widths: Vec<usize> = delta_widths
.iter()
.map(|w| w / total_delta * remaining_width as f64)
.map(|w| w.round() as usize)
.collect();
for (min_col_width, added_width) in min_column_widths.iter_mut().zip(added_widths) {
*min_col_width += added_width;
}
while required_row_width(min_column_widths, infix) > max_width {
let max_width = min_column_widths
.iter_mut()
.max()
.expect("min_column_width should not be empty after check at start of function");
*max_width -= 1;
}
}
pub fn render_table_row(
cells: &[RenderedTableCell],
column_widths: &[usize],
indend: &str,
separator: &str,
config: &RenderConfig,
) -> Vec<Line<'static>> {
let mut sep = indend;
if let Some(cell) = cells.first() {
let len = cell.content.len();
(0..len)
.map(|i| {
sep = indend;
Line::from(
cells
.iter()
.enumerate()
.flat_map(|(j, cell)| {
let mut line =
vec![Span::styled(sep.to_string(), config.style.clone())];
sep = separator;
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()
}
}
fn render_table_with_constraints(
prerendered_table: &mut RenderedTable,
column_widths: &[usize],
separator: &str,
separator_prefill: usize,
config: &RenderConfig,
) -> Vec<Line<'static>> {
let indend = if (config.width as usize).saturating_sub(column_widths.iter().sum()) > 0 {
" "
} else {
""
};
for row in &mut prerendered_table.content {
for (col, cell) in row.content.iter_mut().enumerate() {
cell.content = wrap_lines(cell.content.clone(), column_widths[col] as u16, None);
}
}
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,
column_widths,
indend,
separator,
config,
));
}
let mut spans = Vec::new();
if let Some(column) = column_widths.first() {
spans.push(Span::styled(
format!("{}{}", indend, "─".repeat(*column)),
config.style.clone(),
));
}
spans.extend(column_widths.iter().skip(1).map(|l| {
Span::styled(
format!(
"{}┼{}",
"─".repeat(separator_prefill),
"─".repeat((l + separator.width()).saturating_sub(separator_prefill + 1))
),
config.style.clone(),
)
}));
lines.push(Line::from(spans));
lines.extend(
prerendered_table.content.iter().skip(1).flat_map(|row| {
render_table_row(&row.content, column_widths, indend, separator, config)
}),
);
lines
}
pub fn render_code(
content: &str,
lang: &str,
copy_id: u32,
config: &RenderConfig,
) -> Vec<Line<'static>> {
let config = RenderConfig {
style: TextStyle {
bg_color: DARK_GRAY3,
..config.style.clone()
},
width: config.width - *GUTTER_WIDTH,
..config.clone()
};
let mut header_style = config.style.clone();
header_style.bg_color = DARK_GRAY3;
header_style.italic = true;
let mut content_lines = wrap_lines(
highlight_code(content, lang, &config),
config.width - 1, Some(Span::styled(
config.symbols.line_wrap_indicator.clone(),
config.style.clone(),
)),
);
let mut max_width = content_lines
.iter()
.fold(0, |max_width, line| std::cmp::max(max_width, line.width()));
max_width += 1; for line in content_lines.iter_mut() {
line.push_span(Span::styled(
" ".repeat(max_width.saturating_sub(line.width())),
config.style.clone(),
))
}
let lang_marker = format!("{} {}", config.symbols.language_indicator, lang);
let copy_marker = format!("{} {} ", config.symbols.copy_indicator, 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,
wrap_indicator: Option<Span<'static>>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.flat_map(|line| wrap_line(line, width, 0, wrap_indicator.clone()))
.collect()
}
pub fn wrap_line(
line: Line<'static>,
width: u16,
position: usize,
wrap_indicator: Option<Span<'static>>,
) -> 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();
if let Some(indicator) = wrap_indicator.clone() {
position = indicator.width();
current_line.push(indicator);
} else {
position = 0;
}
current_span = Some(span2);
}
(None, Some(span)) => {
lines.push(Line::from(current_line.clone()));
current_line.clear();
if let Some(indicator) = wrap_indicator.clone() {
position = indicator.width();
current_line.push(indicator);
} else {
position = 0;
}
current_span = Some(span);
}
(None, None) => {
tracing::warn!(
"Hit a case of breaking spans resulting in both spans being 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().map(ToOwned::to_owned)
&& UnicodeWidthStr::width(w) >= width
{
let mut old_second = second;
second = Vec::new();
for chunk in w.split_inclusive(|c: char| !c.is_alphanumeric()) {
let w = UnicodeWidthStr::width(chunk);
pos += w;
if pos <= width {
first.push(chunk);
} else {
second.push(chunk);
}
}
second.append(&mut old_second);
}
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)
}
fn min_column_width(
prerendered_table: &RenderedTable,
num_columns: usize,
split_condition: fn(char) -> bool,
) -> Vec<usize> {
let mut widths = vec![0; num_columns];
for row in &prerendered_table.content {
for (col, cell) in row.content.iter().enumerate() {
let max_cell_width = cell
.content
.iter()
.flat_map(|line| {
line.to_string()
.split(split_condition)
.map(|elem| elem.to_owned())
.collect::<Vec<String>>()
})
.map(|elem| elem.width())
.max()
.unwrap_or(0);
widths[col] = widths[col].max(max_cell_width);
}
}
widths
}
fn prefered_column_width(prerendered_table: &RenderedTable, num_columns: usize) -> Vec<usize> {
let prefered_cell_width: Vec<Vec<usize>> = prerendered_table
.content
.iter()
.map(|row| {
row.content
.iter()
.map(|cell| {
cell.content
.iter()
.map(|line| line.width())
.max()
.unwrap_or_default()
})
.collect()
})
.collect();
prefered_cell_width
.iter()
.fold(vec![0; num_columns], |widths, row| {
widths
.into_iter()
.zip(row)
.map(|(current, candidate)| max(current, *candidate))
.collect()
})
}
fn prerender_table(content: &[TableRow], config: &RenderConfig) -> RenderedTable {
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();
RenderedTable { content: rows }
}
fn render_table_when_within_constraints(
prerendered_table: &mut RenderedTable,
mut requested_column_widths: Vec<usize>,
prefered_column_widths: &[usize],
separator: &str,
separator_prefill: usize,
config: &RenderConfig,
) -> Option<Vec<Line<'static>>> {
if required_row_width(&requested_column_widths, separator) <= config.width.into() {
enlarge_columns(
&mut requested_column_widths,
prefered_column_widths,
separator,
config.width.into(),
);
Some(render_table_with_constraints(
prerendered_table,
&requested_column_widths,
separator,
separator_prefill,
config,
))
} else {
None
}
}
fn required_row_width(column_widths: &[usize], separator: &str) -> usize {
column_widths.iter().sum::<usize>() + (column_widths.len() - 1) * separator.width()
}