use ratatui::{
style::{Color, Modifier, Style, Stylize},
text::Span,
};
use unicode_width::UnicodeWidthStr;
use crate::{
config::Symbols,
note_editor::{
ast::{self, SourceRange},
rich_text::RichText,
text_wrap::wrap_preserve_trailing,
virtual_document::{
content_span, empty_virtual_line, synthetic_span, virtual_line, VirtualBlock,
VirtualLine, VirtualSpan,
},
},
stylized_text::stylize,
};
trait SpanExt {
fn merge(self, other: Span) -> Span;
}
impl SpanExt for &Span<'_> {
fn merge(self, other: Span) -> Span {
Span::styled(
format!("{}{}", self.content, other.content),
self.style.patch(other.style),
)
}
}
#[derive(Clone, PartialEq, Debug)]
pub enum RenderStyle {
Raw,
Visual,
}
#[allow(clippy::too_many_arguments)]
fn text_wrap_internal<'a>(
text_content: &str,
text_style: Style,
prefix: Span<'static>,
source_range: &SourceRange<usize>,
width: usize,
marker: Option<Span<'static>>,
option: &RenderStyle,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
let wrap_marker = &symbols.wrap_marker;
let wrapped_lines = wrap_preserve_trailing(text_content, width, wrap_marker.width() + 1);
let mut current_range_start = source_range.start;
wrapped_lines
.iter()
.enumerate()
.map(|(i, line)| {
let line_byte_len = line.len();
let line_source_range =
current_range_start..(current_range_start + line_byte_len).min(source_range.end);
current_range_start += line_byte_len;
let first_line = i == 0;
let content_span = Span::styled(line.to_string(), text_style);
match (&marker, first_line) {
(Some(marker), true) if *option == RenderStyle::Visual => virtual_line!([
synthetic_span!(prefix),
synthetic_span!(marker),
content_span!(content_span, line_source_range)
]),
(_, true) => virtual_line!([
synthetic_span!(prefix),
content_span!(content_span, line_source_range)
]),
_ => {
let marker_padding = marker.as_ref().map(|m| m.width()).unwrap_or(0);
virtual_line!([
synthetic_span!(prefix),
synthetic_span!(Span::styled(" ".repeat(marker_padding), prefix.style)),
synthetic_span!(Span::styled(wrap_marker.clone(), Style::new().black())),
content_span!(content_span, line_source_range)
])
}
}
})
.collect()
}
fn render_raw_line<'a>(
line: &str,
prefix: Span<'static>,
source_range: &SourceRange<usize>,
max_width: usize,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
text_wrap_internal(
&line.replace("\t", " "),
Style::default(),
prefix,
source_range,
max_width,
None,
&RenderStyle::Raw,
symbols,
)
}
pub fn text_wrap<'a>(
text: &Span<'a>,
prefix: Span<'static>,
source_range: &SourceRange<usize>,
width: usize,
marker: Option<Span<'static>>,
option: &RenderStyle,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
text_wrap_internal(
&text.content,
text.style,
prefix,
source_range,
width,
marker,
option,
symbols,
)
}
#[allow(clippy::too_many_arguments)]
pub fn heading<'a>(
level: ast::HeadingLevel,
content: &str,
prefix: Span<'static>,
text: &RichText,
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
) -> VirtualBlock<'a> {
use ast::HeadingLevel::*;
let (text, heading_source_range, remaining) = match option {
RenderStyle::Visual => (text.to_string(), source_range.clone(), None),
RenderStyle::Raw => match content.split_once('\n') {
Some((first, rest)) => {
let end = (source_range.start + first.len()).min(source_range.end);
(first.to_string(), source_range.start..end, Some(rest))
}
None => (content.to_string(), source_range.clone(), None),
},
};
let prefix_width = prefix.width();
let h = |marker: Span<'static>, content: Span<'a>| {
let mut wrapped_heading = text_wrap(
&content,
prefix.clone(),
&heading_source_range,
max_width,
Some(marker),
option,
symbols,
);
if matches!(option, RenderStyle::Visual) {
wrapped_heading.push(empty_virtual_line!());
}
wrapped_heading
};
let h_with_underline = |content: Span<'a>, underline: Span<'static>| {
let mut wrapped_heading = text_wrap(
&content,
prefix.clone(),
&heading_source_range,
max_width,
None,
option,
symbols,
);
wrapped_heading.push(virtual_line!([synthetic_span!(underline)]));
wrapped_heading
};
let mut lines = match level {
H1 => h_with_underline(
if *option == RenderStyle::Visual {
text.to_uppercase().bold()
} else {
text.bold()
},
symbols
.h1_underline
.repeat(max_width.saturating_sub(prefix_width))
.into(),
),
H2 => h_with_underline(
text.bold().yellow(),
symbols
.h2_underline
.repeat(max_width.saturating_sub(prefix_width))
.yellow(),
),
H3 => h(format!("{} ", symbols.h3_marker).cyan(), text.bold().cyan()),
H4 => h(
format!("{} ", symbols.h4_marker).magenta(),
text.bold().magenta(),
),
H5 => h(
format!("{} ", symbols.h5_marker).into(),
match symbols.h5_font_style {
Some(style) => stylize(&text, style).into(),
None => text.into(),
},
),
H6 => h(
format!("{} ", symbols.h6_marker).into(),
match symbols.h6_font_style {
Some(style) => stylize(&text, style).into(),
None => text.into(),
},
),
};
if let Some(remaining) = remaining.filter(|str| !str.is_empty()) {
let remaining_start = (heading_source_range.end + 1).min(source_range.end);
let remaining_range = remaining_start..source_range.end;
lines.extend(render_raw(
remaining,
&remaining_range,
max_width,
prefix.clone(),
symbols,
));
}
VirtualBlock::new(&lines, source_range)
}
pub fn render_raw<'a>(
content: &str,
source_range: &SourceRange<usize>,
max_width: usize,
prefix: Span<'static>,
symbols: &Symbols,
) -> Vec<VirtualLine<'a>> {
let mut current_range_start = source_range.start;
let mut lines = content
.lines()
.flat_map(|line| {
let line_range = line_range(current_range_start, line.len(), true);
current_range_start = line_range.end;
if line.is_empty() {
vec![virtual_line!([
synthetic_span!(prefix.clone()),
content_span!("".to_string(), line_range)
])]
} else {
render_raw_line(line, prefix.clone(), &line_range, max_width, symbols)
}
})
.collect::<Vec<_>>();
if lines.is_empty() {
lines.push(virtual_line!([
synthetic_span!(prefix.clone()),
content_span!("".to_string(), source_range)
]));
}
lines.push(empty_virtual_line!());
lines
}
pub fn paragraph<'a>(
content: &str,
prefix: Span<'static>,
text: &RichText,
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual => {
let text = text.to_string();
let mut current_range_start = source_range.start;
let mut lines = text
.to_string()
.lines()
.flat_map(|line| {
let line_range = line_range(current_range_start, line.len(), true);
current_range_start = line_range.end;
text_wrap(
&line.to_string().into(),
prefix.clone(),
&line_range,
max_width,
None,
option,
symbols,
)
})
.collect::<Vec<_>>();
if prefix.to_string().is_empty() {
lines.extend([empty_virtual_line!()]);
}
lines
}
};
VirtualBlock::new(&lines, source_range)
}
pub fn code_block<'a>(
content: &str,
prefix: Span<'static>,
_lang: &Option<String>,
text: &RichText,
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => {
let mut current_range_start = source_range.start;
let mut lines = content
.lines()
.map(|line| {
let line_range = line_range(current_range_start, line.len(), true);
current_range_start = line_range.end;
virtual_line!([
synthetic_span!(prefix.clone()),
synthetic_span!(Span::styled(" ", Style::new().bg(Color::Black))),
content_span!(line.to_string().bg(Color::Black), line_range),
synthetic_span!(" "
.repeat(
max_width
.saturating_sub(prefix.width() + line.chars().count())
.saturating_sub(1)
)
.bg(Color::Black)),
])
})
.collect::<Vec<_>>();
lines.push(empty_virtual_line!());
lines
}
RenderStyle::Visual => {
let text = text.to_string();
let padding_line = virtual_line!([
synthetic_span!(prefix.clone()),
synthetic_span!(" "
.repeat(max_width.saturating_sub(prefix.width()))
.bg(Color::Black))
]);
let mut current_range_start = source_range.start;
let mut lines = vec![padding_line.clone()];
lines.extend(text.lines().map(|line| {
let source_range = line_range(current_range_start, line.len(), true);
current_range_start = source_range.end;
virtual_line!([
synthetic_span!(prefix.clone()),
synthetic_span!(Span::styled(" ", Style::new().bg(Color::Black))),
content_span!(line.to_string().bg(Color::Black), source_range),
synthetic_span!(" "
.repeat(
max_width
.saturating_sub(prefix.width() + line.chars().count())
.saturating_sub(1)
)
.bg(Color::Black)),
])
}));
lines.extend([padding_line]);
lines.extend([empty_virtual_line!()]);
lines
}
};
VirtualBlock::new(&lines, source_range)
}
#[allow(clippy::too_many_arguments)]
pub fn list<'a>(
content: &str,
prefix: Span<'static>,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual => {
let mut lines: Vec<VirtualLine<'a>> = nodes
.iter()
.flat_map(|node| {
let node_content = content
.get(node.source_range().clone())
.unwrap_or("")
.to_string();
render_node(
node_content,
node,
max_width,
prefix.clone(),
option,
symbols,
list_depth,
)
.lines
})
.collect();
if prefix.to_string().is_empty() {
lines.extend([empty_virtual_line!()]);
}
lines
}
};
VirtualBlock::new(&lines, source_range)
}
#[allow(clippy::too_many_arguments)]
pub fn task<'a>(
content: &str,
prefix: Span<'static>,
kind: &ast::TaskKind,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual => {
let Some((text, rest)) = nodes.split_first().and_then(|(first, rest)| {
let text = first.rich_text()?;
Some((text, rest))
}) else {
return VirtualBlock::new(&[], source_range);
};
let text = text.to_string();
let text = match option {
RenderStyle::Visual => text,
RenderStyle::Raw => content.to_string(),
};
let (marker, text) = match kind {
ast::TaskKind::Unchecked => (
format!("{} ", symbols.task_unchecked).dark_gray(),
text.into(),
),
ast::TaskKind::LooselyChecked => (
format!("{} ", symbols.task_checked).magenta(),
text.dark_gray(),
),
ast::TaskKind::Checked => (
format!("{} ", symbols.task_checked).magenta(),
text.dark_gray().add_modifier(Modifier::CROSSED_OUT),
),
};
let mut lines = text_wrap(
&text,
prefix.clone(),
source_range,
max_width,
Some(marker),
option,
symbols,
);
lines.extend(rest.iter().flat_map(|node| {
render_node(
content.to_string(),
node,
max_width,
prefix.merge(" ".into()),
option,
symbols,
list_depth + 1,
)
.lines
}));
lines
}
};
VirtualBlock::new(&lines, source_range)
}
#[allow(clippy::too_many_arguments)]
pub fn item<'a>(
content: &str,
prefix: Span<'static>,
kind: &ast::ItemKind,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual => {
let Some((text, rest)) = nodes.split_first().and_then(|(first, rest)| {
let text = first.rich_text()?;
Some((text, rest))
}) else {
return VirtualBlock::new(&[], source_range);
};
let text = text.to_string();
let marker = match kind {
ast::ItemKind::Ordered(i) => format!("{i}. ").dark_gray(),
ast::ItemKind::Unordered => {
let marker = if symbols.list_markers.is_empty() {
"-"
} else {
&symbols.list_markers[list_depth % symbols.list_markers.len()]
};
format!("{marker} ").dark_gray()
}
};
let mut lines = text_wrap(
&text.into(),
prefix.clone(),
source_range,
max_width,
Some(marker),
option,
symbols,
);
lines.extend(rest.iter().flat_map(|node| {
render_node(
content.to_string(),
node,
max_width,
prefix.merge(" ".into()),
option,
symbols,
list_depth + 1,
)
.lines
}));
lines
}
};
VirtualBlock::new(&lines, source_range)
}
pub fn line_range(start: usize, line_width: usize, newline: bool) -> SourceRange<usize> {
let end = start + line_width + if newline { 1 } else { 0 };
start..end
}
#[allow(clippy::too_many_arguments)]
pub fn block_quote<'a>(
content: &str,
prefix: Span<'static>,
_kind: &Option<ast::BlockQuoteKind>,
nodes: &[ast::Node],
source_range: &SourceRange<usize>,
max_width: usize,
option: &RenderStyle,
symbols: &Symbols,
) -> VirtualBlock<'a> {
let lines = match option {
RenderStyle::Raw => render_raw(content, source_range, max_width, prefix, symbols),
RenderStyle::Visual => nodes
.iter()
.enumerate()
.flat_map(|(i, node)| {
let mut lines = render_node(
content.to_string(),
node,
max_width,
prefix.merge(Span::raw("┃ ").magenta()),
option,
symbols,
0,
)
.lines;
if prefix.to_string().is_empty() && i != nodes.len().saturating_sub(1) {
lines.extend([virtual_line!([synthetic_span!(Span::raw("┃ ").magenta())])]);
}
if prefix.to_string().is_empty() && i == nodes.len().saturating_sub(1) {
lines.extend([empty_virtual_line!()]);
}
lines
})
.collect::<Vec<_>>(),
};
VirtualBlock::new(&lines, source_range)
}
pub fn render_node<'a>(
content: String,
node: &ast::Node,
max_width: usize,
prefix: Span<'static>,
option: &RenderStyle,
symbols: &Symbols,
list_depth: usize,
) -> VirtualBlock<'a> {
use ast::Node::*;
match node {
Heading {
level,
text,
source_range,
} => heading(
*level,
&content,
prefix,
text,
source_range,
max_width,
option,
symbols,
),
Paragraph { text, source_range } => paragraph(
&content,
prefix,
text,
source_range,
max_width,
option,
symbols,
),
CodeBlock {
lang,
text,
source_range,
} => code_block(
&content,
prefix,
lang,
text,
source_range,
max_width,
option,
),
List {
nodes,
source_range,
} => list(
&content,
prefix,
nodes,
source_range,
max_width,
option,
symbols,
list_depth,
),
Item {
kind,
nodes,
source_range,
} => item(
&content,
prefix,
kind,
nodes,
source_range,
max_width,
option,
symbols,
list_depth,
),
Task {
kind,
nodes,
source_range,
} => task(
&content,
prefix,
kind,
nodes,
source_range,
max_width,
option,
symbols,
list_depth,
),
BlockQuote {
kind,
nodes,
source_range,
} => block_quote(
&content,
prefix,
kind,
nodes,
source_range,
max_width,
option,
symbols,
),
}
}