use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span as RatatuiSpan},
};
use crate::{
Line as MdLine, LineKind, MarkdownLink, Span,
mapper::Mapper,
markdown::{Modifier as MdModifier, SourceContent},
};
pub trait Theme: Mapper {
fn blockquote_color(&self, depth: usize) -> Color {
const COLORS: [Color; 6] = [
Color::Indexed(202),
Color::Indexed(203),
Color::Indexed(204),
Color::Indexed(205),
Color::Indexed(206),
Color::Indexed(207),
];
COLORS[depth % COLORS.len()]
}
fn link_bg(&self) -> Color {
Color::Indexed(237)
}
fn link_fg(&self) -> Color {
Color::Indexed(4)
}
fn prefix_color(&self) -> Color {
Color::Indexed(189)
}
fn emphasis_color(&self) -> Color {
Color::Indexed(220)
}
fn code_bg(&self) -> Color {
Color::Indexed(236)
}
fn code_fg(&self) -> Color {
Color::Indexed(203)
}
fn hr_color(&self) -> Color {
Color::Indexed(240)
}
fn table_border_color(&self) -> Color {
Color::Indexed(240)
}
fn table_header_color(&self) -> Color {
Color::Indexed(255)
}
fn code_style(&self) -> Style {
Style::default().fg(self.code_fg()).bg(self.code_bg())
}
fn emphasis_style(&self) -> Style {
Style::default()
.add_modifier(Modifier::ITALIC)
.fg(self.emphasis_color())
}
fn strong_emphasis_style(&self) -> Style {
Style::default()
.add_modifier(Modifier::BOLD)
.fg(self.emphasis_color())
}
fn link_url_style(&self) -> Style {
Style::default()
.fg(self.link_fg())
.bg(self.link_bg())
.underlined()
}
fn link_description_style(&self) -> Style {
Style::default()
.fg(self.link_fg())
.bg(self.link_bg())
.underlined()
}
fn link_wrapper_style(&self) -> Style {
Style::default().fg(self.link_bg())
}
fn hr_style(&self) -> Style {
Style::default().fg(self.hr_color())
}
fn table_border_style(&self) -> Style {
Style::default().fg(self.table_border_color())
}
fn table_header_style(&self) -> Style {
Style::default()
.add_modifier(Modifier::BOLD)
.fg(self.table_header_color())
}
fn prefix_style(&self) -> Style {
Style::default().fg(self.prefix_color())
}
fn blockquote_style(&self, depth: usize) -> Style {
Style::default().fg(self.blockquote_color(depth))
}
fn strikethrough_color(&self) -> Color {
Color::Indexed(245)
}
fn strikethrough_style(&self) -> Style {
Style::default()
.add_modifier(Modifier::CROSSED_OUT | Modifier::DIM)
.fg(self.strikethrough_color())
}
}
#[derive(Debug, Clone, Copy, Default)]
pub struct DefaultTheme;
const STYLED: crate::mapper::StyledMapper = crate::mapper::StyledMapper;
impl Mapper for DefaultTheme {
fn link_desc_open(&self) -> &str {
STYLED.link_desc_open()
}
fn link_desc_close(&self) -> &str {
STYLED.link_desc_close()
}
fn link_url_open(&self) -> &str {
STYLED.link_url_open()
}
fn link_url_close(&self) -> &str {
STYLED.link_url_close()
}
fn blockquote_bar(&self) -> &str {
STYLED.blockquote_bar()
}
fn horizontal_rule_char(&self) -> &str {
STYLED.horizontal_rule_char()
}
fn task_checked(&self) -> &str {
STYLED.task_checked()
}
fn table_vertical(&self) -> &str {
STYLED.table_vertical()
}
fn table_horizontal(&self) -> &str {
STYLED.table_horizontal()
}
fn table_top_left(&self) -> &str {
STYLED.table_top_left()
}
fn table_top_right(&self) -> &str {
STYLED.table_top_right()
}
fn table_bottom_left(&self) -> &str {
STYLED.table_bottom_left()
}
fn table_bottom_right(&self) -> &str {
STYLED.table_bottom_right()
}
fn table_top_junction(&self) -> &str {
STYLED.table_top_junction()
}
fn table_bottom_junction(&self) -> &str {
STYLED.table_bottom_junction()
}
fn table_left_junction(&self) -> &str {
STYLED.table_left_junction()
}
fn table_right_junction(&self) -> &str {
STYLED.table_right_junction()
}
fn table_cross(&self) -> &str {
STYLED.table_cross()
}
fn emphasis_open(&self) -> &str {
STYLED.emphasis_open()
}
fn emphasis_close(&self) -> &str {
STYLED.emphasis_close()
}
fn strong_open(&self) -> &str {
STYLED.strong_open()
}
fn strong_close(&self) -> &str {
STYLED.strong_close()
}
fn code_open(&self) -> &str {
STYLED.code_open()
}
fn code_close(&self) -> &str {
STYLED.code_close()
}
fn strikethrough_open(&self) -> &str {
STYLED.strikethrough_open()
}
fn strikethrough_close(&self) -> &str {
STYLED.strikethrough_close()
}
}
impl Theme for DefaultTheme {}
#[derive(Debug, Clone, PartialEq)]
pub enum Tag {
Image(MarkdownLink),
Link(usize, SourceContent),
Header(u8),
CodeBlock(Option<String>),
HorizontalRule,
TableRow,
TableBorder,
Blank,
}
pub fn render_line<T: Theme>(md_line: MdLine, theme: &T) -> (Line<'static>, Vec<Tag>) {
let MdLine { spans, kind } = md_line;
let mut bq_depth = 0;
let mut line_spans = Vec::with_capacity(spans.len());
let mut tags = Vec::new();
let is_table_header = matches!(kind, LineKind::TableRow { is_header: true });
let is_code_block = matches!(kind, LineKind::CodeBlock { .. });
for node in spans {
if (node.modifiers.contains(MdModifier::LinkDescription)
|| node
.modifiers
.intersects(MdModifier::BareLink | MdModifier::LinkURL))
&& !node.modifiers.contains(MdModifier::LinkURLWrapper)
{
let source_content = node.source_content.clone();
if let Some(link) = source_content {
tags.push(Tag::Link(line_spans.len(), link));
} else {
log::warn!("node has LinkURL but no source_content");
tags.push(Tag::Link(
line_spans.len(),
SourceContent::from(node.content.as_ref()),
));
}
}
let span = node_to_span(
node,
bq_depth,
is_table_header,
is_code_block,
theme,
&mut bq_depth,
);
line_spans.push(span);
}
match kind {
LineKind::Paragraph => {}
LineKind::Header(tier) => tags.push(Tag::Header(tier)),
LineKind::CodeBlock { language } => {
tags.push(Tag::CodeBlock(if language.is_empty() {
None
} else {
Some(language)
}));
}
LineKind::HorizontalRule => tags.push(Tag::HorizontalRule),
LineKind::TableRow { .. } => tags.push(Tag::TableRow),
LineKind::TableBorder => tags.push(Tag::TableBorder),
LineKind::Image(link) => tags.push(Tag::Image(link)),
LineKind::Blank => tags.push(Tag::Blank),
}
(Line::from(line_spans), tags)
}
fn node_to_span<T: Theme>(
node: Span,
current_bq_depth: usize,
is_table_header: bool,
is_code_block: bool,
theme: &T,
bq_depth_out: &mut usize,
) -> RatatuiSpan<'static> {
let Span {
content,
modifiers,
..
} = node;
if modifiers.contains(MdModifier::BlockquoteBar) {
let style = theme.blockquote_style(current_bq_depth);
*bq_depth_out = current_bq_depth + 1;
return RatatuiSpan::styled(content, style);
}
if modifiers.contains(MdModifier::ListMarker) {
return RatatuiSpan::styled(content, theme.prefix_style());
}
if modifiers.contains(MdModifier::TableBorder) {
return RatatuiSpan::styled(content, theme.table_border_style());
}
if modifiers.contains(MdModifier::HorizontalRule) {
return RatatuiSpan::styled(content, theme.hr_style());
}
if modifiers.contains(MdModifier::Code) || is_code_block {
return RatatuiSpan::styled(content, theme.code_style());
}
if modifiers.contains(MdModifier::LinkDescriptionWrapper)
|| modifiers.contains(MdModifier::LinkURLWrapper)
{
return RatatuiSpan::styled(content, theme.link_wrapper_style());
}
if modifiers.contains(MdModifier::LinkURL) {
return RatatuiSpan::styled(content, theme.link_url_style());
}
let mut style = if is_table_header {
theme.table_header_style()
} else {
Style::default()
};
if modifiers.contains(MdModifier::LinkDescription) {
style = style.patch(theme.link_description_style());
}
if modifiers.contains(MdModifier::Emphasis) {
style = style.patch(theme.emphasis_style());
}
if modifiers.contains(MdModifier::StrongEmphasis) {
style = style.patch(theme.strong_emphasis_style());
}
if modifiers.contains(MdModifier::Strikethrough) {
style = style.patch(theme.strikethrough_style());
}
if modifiers.contains(MdModifier::Link) {
style = style.patch(theme.link_description_style());
}
if style == Style::default() {
RatatuiSpan::from(content)
} else {
RatatuiSpan::styled(content, style)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn split_url_tag_uses_source_content() {
let full_url = "https://example.com/path";
let source = SourceContent::from(full_url);
let md_line = MdLine {
spans: vec![
Span::new("See ".into(), MdModifier::empty()),
Span::new("(".into(), MdModifier::LinkURLWrapper),
Span::source_link("https://".into(), MdModifier::LinkURL, source.clone()),
Span::source_link("example".into(), MdModifier::LinkURL, source.clone()),
Span::source_link(".com/path".into(), MdModifier::LinkURL, source.clone()),
Span::new(")".into(), MdModifier::LinkURLWrapper),
],
kind: LineKind::Paragraph,
};
let (_line, tags) = render_line(md_line, &DefaultTheme);
let link_tags: Vec<_> = tags
.iter()
.filter_map(|t| {
if let Tag::Link(_, url) = t {
Some(url)
} else {
None
}
})
.collect();
assert_eq!(link_tags.len(), 3);
assert!(link_tags.iter().all(|url| url.as_ref() == full_url));
}
#[test]
fn url_tag_without_source_content() {
let md_line = MdLine {
spans: vec![Span::new("https://example.com".into(), MdModifier::LinkURL)],
kind: LineKind::Paragraph,
};
let (_line, tags) = render_line(md_line, &DefaultTheme);
let link_tags: Vec<_> = tags
.iter()
.filter_map(|t| {
if let Tag::Link(_, url) = t {
Some(url.as_ref())
} else {
None
}
})
.collect();
assert_eq!(link_tags, vec!["https://example.com"]);
}
}