use crate::settings::themes::Theme;
use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Modifier, Style};
use ratatui::text::Span;
use unicode_segmentation::UnicodeSegmentation;
const PARSER_OPTIONS: Options = Options::ENABLE_STRIKETHROUGH;
const TAB_STOP: usize = 4;
fn tab_width_at(col: usize) -> usize {
TAB_STOP - (col % TAB_STOP)
}
fn string_display_width(s: &str) -> usize {
s.graphemes(true).map(cluster_display_width).sum()
}
fn cluster_display_width(cluster: &str) -> usize {
cluster
.chars()
.next()
.and_then(unicode_width::UnicodeWidthChar::width)
.unwrap_or(1)
}
#[derive(Debug, Clone, PartialEq)]
pub struct Element {
pub start_char: usize,
pub end_char: usize,
pub kind: ElementKind,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum ElementKind {
Bold,
Italic,
Strikethrough,
InlineCode,
Link,
HeadingH1,
HeadingH2,
HeadingH3,
Blockquote,
WikiLink,
Image,
Label,
}
#[derive(Debug, Clone)]
pub struct ImagePlaceholder {
pub start_char: usize,
pub end_char: usize,
pub placeholder: String,
pub placeholder_width: usize,
}
#[derive(Debug, Clone)]
pub struct ParsedLine {
pub elements: Vec<Element>,
pub content_vis: Vec<bool>,
elem_vis: Vec<bool>,
elem_index: Vec<u16>,
list_sigil_end: Option<usize>,
pub image_placeholders: Vec<ImagePlaceholder>,
}
impl ParsedLine {
pub fn parse(line: &str) -> Self {
let owned = line.to_string();
if needs_synthetic_list_parent(line) {
ParsedBuffer::parse(&["- ".to_string(), owned])
.into_iter()
.last()
.expect("ParsedBuffer::parse returns one row per input line")
} else {
ParsedBuffer::parse(std::slice::from_ref(&owned))
.pop()
.expect("ParsedBuffer::parse always returns at least one ParsedLine")
}
}
pub fn elem_at(&self, pos: usize) -> Option<usize> {
self.elem_index.get(pos).and_then(|&tag| {
if tag == 0 {
None
} else {
Some((tag as usize) - 1)
}
})
}
pub fn in_any_element(&self, pos: usize) -> bool {
self.elem_vis.get(pos).copied().unwrap_or(false)
}
pub fn heading_sigil_end(&self) -> Option<usize> {
self.elements
.iter()
.find(|e| {
matches!(
e.kind,
ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
)
})
.map(|e| {
let mut first_content = e.end_char; for i in e.start_char..e.end_char {
if i < self.content_vis.len() && self.content_vis[i] {
first_content = i;
break;
}
}
first_content
})
}
pub fn list_sigil_end(&self) -> Option<usize> {
self.list_sigil_end
}
}
pub struct ParsedBuffer;
impl ParsedBuffer {
pub fn parse(lines: &[String]) -> Vec<ParsedLine> {
let total_bytes: usize =
lines.iter().map(|l| l.len()).sum::<usize>() + lines.len().saturating_sub(1);
let mut joined = String::with_capacity(total_bytes);
let mut line_starts: Vec<usize> = Vec::with_capacity(lines.len() + 1);
for (i, line) in lines.iter().enumerate() {
line_starts.push(joined.len());
joined.push_str(line);
if i + 1 < lines.len() {
joined.push('\n');
}
}
line_starts.push(joined.len() + 1);
let mut content_vis: Vec<Vec<bool>> = lines
.iter()
.map(|l| vec![false; l.chars().count()])
.collect();
let mut elements: Vec<Vec<Element>> = vec![Vec::new(); lines.len()];
let mut list_sigil_end: Vec<Option<usize>> = vec![None; lines.len()];
let mut stack: Vec<(usize, usize, ElementKind)> = Vec::new();
let emit_span = |row_s: usize,
col_s: usize,
row_e: usize,
col_e: usize,
kind: ElementKind,
elements: &mut Vec<Vec<Element>>,
lines: &[String]| {
if row_s == row_e {
if col_e > col_s && row_s < elements.len() {
elements[row_s].push(Element {
start_char: col_s,
end_char: col_e,
kind,
});
}
return;
}
if row_s < elements.len() {
let end_first = lines[row_s].chars().count();
if end_first > col_s {
elements[row_s].push(Element {
start_char: col_s,
end_char: end_first,
kind,
});
}
}
for r in (row_s + 1)..row_e {
if r < elements.len() {
let line_len = lines[r].chars().count();
if line_len > 0 {
elements[r].push(Element {
start_char: 0,
end_char: line_len,
kind,
});
}
}
}
if row_e < elements.len() && col_e > 0 {
elements[row_e].push(Element {
start_char: 0,
end_char: col_e,
kind,
});
}
};
let mut code_block_byte_ranges: Vec<(usize, usize)> = Vec::new();
let mut code_block_depth = 0u32;
let mut code_block_start: Option<usize> = None;
let parser = Parser::new_ext(&joined, PARSER_OPTIONS);
for (event, range) in parser.into_offset_iter() {
let (sr, sc) = byte_to_row_col(range.start, lines, &line_starts);
let (er, ec) = byte_to_row_col(range.end, lines, &line_starts);
match event {
Event::Start(Tag::CodeBlock(_)) => {
if code_block_depth == 0 {
code_block_start = Some(range.start);
}
code_block_depth += 1;
}
Event::End(TagEnd::CodeBlock) => {
code_block_depth = code_block_depth.saturating_sub(1);
if code_block_depth == 0
&& let Some(start) = code_block_start.take()
{
code_block_byte_ranges.push((start, range.end));
}
}
Event::Start(ref tag) if let Some(kind) = tag_to_kind(tag) => {
stack.push((sr, sc, kind));
}
Event::End(
TagEnd::Strong
| TagEnd::Emphasis
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Heading(_)
| TagEnd::BlockQuote(_),
) => {
if let Some((s_r, s_c, k)) = stack.pop() {
emit_span(s_r, s_c, er, ec, k, &mut elements, lines);
}
}
Event::Start(Tag::Item)
if sr < lines.len() && list_sigil_end[sr].is_none() =>
{
let line = lines[sr].as_str();
let ws_end = leading_ws_byte_len(line);
if let Some(len) = list_marker_len(&line[ws_end..]) {
list_sigil_end[sr] = Some(ws_end + len);
}
}
Event::End(TagEnd::Item) => {}
Event::Code(ref code_text) if sr == er && sr < lines.len() => {
let code_len = code_text.chars().count();
let range_char_len = ec.saturating_sub(sc);
let sigil_each = range_char_len.saturating_sub(code_len) / 2;
let cs = sc + sigil_each;
for vis in content_vis[sr].iter_mut().skip(cs).take(code_len) {
*vis = true;
}
elements[sr].push(Element {
start_char: sc,
end_char: ec,
kind: ElementKind::InlineCode,
});
}
Event::Text(_) | Event::SoftBreak | Event::HardBreak => {
if sr == er {
if sr < content_vis.len() {
for vis in content_vis[sr]
.iter_mut()
.skip(sc)
.take(ec.saturating_sub(sc))
{
*vis = true;
}
}
} else {
if sr < content_vis.len() {
let line_chars = content_vis[sr].len();
for vis in content_vis[sr]
.iter_mut()
.skip(sc)
.take(line_chars.saturating_sub(sc))
{
*vis = true;
}
}
for r in (sr + 1)..er {
if r < content_vis.len() {
for vis in content_vis[r].iter_mut() {
*vis = true;
}
}
}
if er < content_vis.len() {
for vis in content_vis[er].iter_mut().take(ec) {
*vis = true;
}
}
}
}
_ => {}
}
}
let line_in_code_block: Vec<bool> = {
let mut flags = vec![false; lines.len()];
for (cb_start, cb_end) in &code_block_byte_ranges {
for (row, &ls) in line_starts[..lines.len()].iter().enumerate() {
let le = ls + lines[row].len();
if ls < *cb_end && le > *cb_start {
flags[row] = true;
}
}
}
flags
};
let mut out: Vec<ParsedLine> = Vec::with_capacity(lines.len());
for (row, line) in lines.iter().enumerate() {
let mut cv = std::mem::take(&mut content_vis[row]);
let mut els = std::mem::take(&mut elements[row]);
for e in &els {
if matches!(
e.kind,
ElementKind::HeadingH1 | ElementKind::HeadingH2 | ElementKind::HeadingH3
) {
for i in (e.start_char..e.end_char).rev() {
match line.chars().nth(i) {
Some(' ' | '\t') => {
if i < cv.len() {
cv[i] = true;
}
}
_ => break,
}
}
}
}
detect_wikilinks(line, &mut cv, &mut els);
let image_placeholders = detect_image_placeholders(line, &mut cv, &mut els);
if !line_in_code_block[row] {
let line_str = line.as_str();
for lm in kimun_core::note::label_matches(line_str) {
let start_char = line_str[..lm.byte_start].chars().count();
let end_char =
start_char + line_str[lm.byte_start..lm.byte_end].chars().count();
let overlaps_existing = els.iter().any(|e| {
matches!(
e.kind,
ElementKind::InlineCode
| ElementKind::Link
| ElementKind::WikiLink
| ElementKind::Image
) && !(end_char <= e.start_char || start_char >= e.end_char)
});
if !overlaps_existing {
els.push(Element {
start_char,
end_char,
kind: ElementKind::Label,
});
}
}
}
els.sort_by_key(|e| e.start_char);
debug_assert!(
els.len() < u16::MAX as usize,
"Too many elements on a single line ({})",
els.len()
);
let total = line.chars().count();
let mut elem_vis = vec![false; total];
let mut elem_index = vec![0u16; total];
for (i, e) in els.iter().enumerate() {
let tag = (i + 1) as u16;
for pos in e.start_char..e.end_char {
if pos < total {
elem_vis[pos] = true;
elem_index[pos] = tag;
}
}
}
out.push(ParsedLine {
elements: els,
content_vis: cv,
elem_vis,
elem_index,
list_sigil_end: list_sigil_end[row],
image_placeholders,
});
}
out
}
}
pub struct MarkdownSpanner;
impl MarkdownSpanner {
#[cfg(test)]
pub fn parse_elements(line: &str) -> Vec<Element> {
let parser = Parser::new_ext(line, PARSER_OPTIONS);
let mut elements = Vec::new();
let mut stack: Vec<(usize, ElementKind)> = Vec::new();
for (event, range) in parser.into_offset_iter() {
let sc = line[..range.start].chars().count();
let ec = line[..range.end].chars().count();
match event {
Event::Start(ref tag) if let Some(kind) = tag_to_kind(tag) => {
stack.push((sc, kind));
}
Event::End(
TagEnd::Strong
| TagEnd::Emphasis
| TagEnd::Strikethrough
| TagEnd::Link
| TagEnd::Heading(_)
| TagEnd::BlockQuote(_),
) => {
if let Some((s, k)) = stack.pop() {
elements.push(Element {
start_char: s,
end_char: ec,
kind: k,
});
}
}
Event::Code(_) => elements.push(Element {
start_char: sc,
end_char: ec,
kind: ElementKind::InlineCode,
}),
_ => {}
}
}
let mut dummy_vis = vec![true; line.chars().count()];
detect_wikilinks(line, &mut dummy_vis, &mut elements);
elements
}
#[cfg(test)]
#[allow(clippy::too_many_arguments)]
pub fn render(
content: &str,
logical_line: &str,
visual_start_col: usize,
cursor_col: Option<usize>,
is_first_visual_line: bool,
force_raw: bool,
available_width: u16,
theme: &Theme,
) -> Vec<Span<'static>> {
let parsed = ParsedLine::parse(logical_line);
Self::render_with(
content,
logical_line,
&parsed,
visual_start_col,
cursor_col,
is_first_visual_line,
force_raw,
available_width,
theme,
)
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect()
}
#[cfg(test)]
pub fn rendered_cursor_col(
logical_line: &str,
visual_start_col: usize,
cursor_col: usize,
is_first_visual_line: bool,
force_raw: bool,
) -> usize {
let parsed = ParsedLine::parse(logical_line);
Self::rendered_cursor_col_with(
logical_line,
&parsed,
visual_start_col,
cursor_col,
is_first_visual_line,
force_raw,
)
}
#[cfg(test)]
pub fn visible_positions(
logical_line: &str,
cursor_col: Option<usize>,
force_raw: bool,
) -> Vec<bool> {
let parsed = ParsedLine::parse(logical_line);
Self::visible_positions_with(logical_line, &parsed, cursor_col, force_raw)
}
#[cfg(test)]
pub fn rendered_col_to_logical(
logical_line: &str,
visual_start_col: usize,
rendered_col: usize,
is_first_visual_line: bool,
force_raw: bool,
) -> usize {
let parsed = ParsedLine::parse(logical_line);
Self::rendered_col_to_logical_with(
logical_line,
&parsed,
visual_start_col,
rendered_col,
is_first_visual_line,
force_raw,
)
}
#[allow(clippy::too_many_arguments)]
pub fn render_with<'a>(
content: &'a str,
logical_line: &'a str,
parsed: &'a ParsedLine,
visual_start_col: usize,
cursor_col: Option<usize>,
is_first_visual_line: bool,
force_raw: bool,
available_width: u16,
theme: &Theme,
) -> Vec<Span<'a>> {
let trimmed = logical_line.trim();
if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
if cursor_col.is_some() {
return vec![Span::styled(
content,
Style::default().fg(theme.fg_muted.to_ratatui()),
)];
}
return vec![Span::styled(
"─".repeat(available_width as usize),
Style::default().fg(theme.fg_muted.to_ratatui()),
)];
}
if force_raw {
return vec![Span::styled(
content,
Style::default().fg(theme.fg_secondary.to_ratatui()),
)];
}
let elements = &parsed.elements;
let content_vis = &parsed.content_vis;
let content_char_count = content.chars().count();
let expanded: Option<usize> = cursor_col.and_then(|c| parsed.elem_at(c));
let heading_sigil_end: Option<usize> = if is_first_visual_line {
parsed.heading_sigil_end()
} else {
None
};
let list_sigil_end: Option<usize> = if is_first_visual_line {
parsed.list_sigil_end()
} else {
None
};
let mut spans: Vec<Span<'a>> = Vec::new();
let mut seg_str: String = String::new();
let mut seg_elem: Option<usize> = None;
let mut seg_is_sigil = false;
let mut seg_is_expanded = false;
let mut visual_col = 0usize;
let flush = |seg_str: &mut String,
seg_elem: Option<usize>,
seg_is_sigil: bool,
seg_is_expanded: bool,
spans: &mut Vec<Span<'a>>| {
if seg_str.is_empty() {
return;
}
let seg = std::mem::take(seg_str);
let style = if seg_is_expanded {
Style::default().fg(theme.fg_muted.to_ratatui())
} else {
span_style(seg_elem.map(|i| elements[i].kind), seg_is_sigil, theme)
};
spans.push(Span::styled(seg, style));
};
let mut char_pos = 0usize;
for cluster in logical_line.graphemes(true) {
let pos = char_pos;
char_pos += cluster.chars().count();
if pos < visual_start_col {
continue;
}
if pos >= visual_start_col + content_char_count {
break;
}
if let Some(img) = parsed
.image_placeholders
.iter()
.find(|p| p.start_char == pos)
{
let cursor_in_image = expanded.is_some_and(|i| {
elements[i].start_char == img.start_char && elements[i].end_char == img.end_char
});
if !cursor_in_image {
flush(
&mut seg_str,
seg_elem,
seg_is_sigil,
seg_is_expanded,
&mut spans,
);
let style = span_style(Some(ElementKind::Image), false, theme);
visual_col += img.placeholder_width;
spans.push(Span::styled(img.placeholder.as_str(), style));
seg_elem = None;
seg_is_sigil = false;
seg_is_expanded = false;
}
}
let is_content = pos < content_vis.len() && content_vis[pos];
let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
let in_expanded_elem = expanded
.is_some_and(|i| elements[i].start_char <= pos && pos < elements[i].end_char);
let this_elem = parsed.elem_at(pos);
let emit = is_content
|| in_heading_sigil
|| in_list_sigil
|| in_expanded_elem
|| this_elem.is_none();
if !emit {
flush(
&mut seg_str,
seg_elem,
seg_is_sigil,
seg_is_expanded,
&mut spans,
);
seg_elem = None;
seg_is_sigil = false;
seg_is_expanded = false;
continue;
}
let this_is_expanded = in_expanded_elem;
let this_is_sigil =
(in_heading_sigil || in_list_sigil) && !is_content && !in_expanded_elem;
if this_elem != seg_elem
|| this_is_sigil != seg_is_sigil
|| this_is_expanded != seg_is_expanded
{
flush(
&mut seg_str,
seg_elem,
seg_is_sigil,
seg_is_expanded,
&mut spans,
);
seg_elem = this_elem;
seg_is_sigil = this_is_sigil;
seg_is_expanded = this_is_expanded;
}
if cluster == "\t" {
let tw = tab_width_at(visual_col);
for _ in 0..tw {
seg_str.push(' ');
}
visual_col += tw;
} else {
seg_str.push_str(cluster);
visual_col += cluster_display_width(cluster);
}
}
flush(
&mut seg_str,
seg_elem,
seg_is_sigil,
seg_is_expanded,
&mut spans,
);
if spans.is_empty() {
spans.push(Span::styled(
content,
Style::default().fg(theme.fg.to_ratatui()),
));
}
spans
}
pub fn rendered_cursor_col_with(
logical_line: &str,
parsed: &ParsedLine,
visual_start_col: usize,
cursor_col: usize,
is_first_visual_line: bool,
force_raw: bool,
) -> usize {
if force_raw {
return cursor_col.saturating_sub(visual_start_col);
}
let trimmed = logical_line.trim();
if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
return cursor_col.saturating_sub(visual_start_col);
}
let elements = &parsed.elements;
let content_vis = &parsed.content_vis;
let logical_char_count = logical_line.chars().count();
let expanded: Option<usize> = parsed.elem_at(cursor_col);
let heading_sigil_end: Option<usize> = if is_first_visual_line {
parsed.heading_sigil_end()
} else {
None
};
let list_sigil_end: Option<usize> = if is_first_visual_line {
parsed.list_sigil_end()
} else {
None
};
let end = cursor_col.min(logical_char_count);
let mut rendered_col = 0usize;
let mut char_pos = 0usize;
for cluster in logical_line.graphemes(true) {
if char_pos >= end {
break;
}
let pos = char_pos;
char_pos += cluster.chars().count();
if pos < visual_start_col {
continue;
}
if let Some(img) = parsed
.image_placeholders
.iter()
.find(|p| p.start_char == pos)
{
let cursor_in_image = expanded.is_some_and(|i| {
elements[i].start_char == img.start_char && elements[i].end_char == img.end_char
});
if !cursor_in_image {
rendered_col += img.placeholder_width;
}
}
let is_content = pos < content_vis.len() && content_vis[pos];
let in_heading_sigil = heading_sigil_end.is_some_and(|s_end| pos < s_end);
let in_list_sigil = list_sigil_end.is_some_and(|s_end| pos < s_end);
let in_expanded_elem = expanded
.is_some_and(|i| elements[i].start_char <= pos && pos < elements[i].end_char);
let in_any_element = parsed.in_any_element(pos);
let visible = is_content
|| in_heading_sigil
|| in_list_sigil
|| in_expanded_elem
|| !in_any_element;
if visible {
rendered_col += if cluster == "\t" {
tab_width_at(rendered_col)
} else {
cluster_display_width(cluster)
};
}
}
rendered_col
}
pub fn visible_positions_with(
logical_line: &str,
parsed: &ParsedLine,
cursor_col: Option<usize>,
force_raw: bool,
) -> Vec<bool> {
let total = logical_line.chars().count();
if total == 0 {
return vec![];
}
if force_raw {
return vec![true; total];
}
let trimmed = logical_line.trim();
if matches!(trimmed, "---" | "***" | "___") {
return vec![true; total];
}
let content_vis = &parsed.content_vis;
let expanded: Option<usize> = cursor_col.and_then(|c| parsed.elem_at(c));
let heading_sigil_end: Option<usize> = parsed.heading_sigil_end();
let list_sigil_end = parsed.list_sigil_end();
(0..total)
.map(|pos| {
let is_content = pos < content_vis.len() && content_vis[pos];
let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
let in_any_element = parsed.in_any_element(pos);
let in_expanded = expanded.is_some_and(|i| {
parsed.elements[i].start_char <= pos && pos < parsed.elements[i].end_char
});
is_content || in_heading_sigil || in_list_sigil || in_expanded || !in_any_element
})
.collect()
}
pub fn rendered_col_to_logical_with(
logical_line: &str,
parsed: &ParsedLine,
visual_start_col: usize,
rendered_col: usize,
is_first_visual_line: bool,
force_raw: bool,
) -> usize {
if force_raw {
return visual_start_col + rendered_col;
}
let trimmed = logical_line.trim();
if is_first_visual_line && matches!(trimmed, "---" | "***" | "___") {
return visual_start_col + rendered_col;
}
let content_vis = &parsed.content_vis;
let logical_char_count = logical_line.chars().count();
let heading_sigil_end: Option<usize> = if is_first_visual_line {
parsed.heading_sigil_end()
} else {
None
};
let list_sigil_end: Option<usize> = if is_first_visual_line {
parsed.list_sigil_end()
} else {
None
};
let mut rendered_count = 0;
let mut char_pos = 0usize;
for cluster in logical_line.graphemes(true) {
let pos = char_pos;
char_pos += cluster.chars().count();
if pos < visual_start_col {
continue;
}
if rendered_count >= rendered_col {
return pos;
}
if let Some(img) = parsed
.image_placeholders
.iter()
.find(|p| p.start_char == pos)
{
if rendered_count + img.placeholder_width > rendered_col {
return pos;
}
rendered_count += img.placeholder_width;
}
let is_content = pos < content_vis.len() && content_vis[pos];
let in_heading_sigil = heading_sigil_end.is_some_and(|end| pos < end);
let in_list_sigil = list_sigil_end.is_some_and(|end| pos < end);
let in_any_element = parsed.in_any_element(pos);
if is_content || in_heading_sigil || in_list_sigil || !in_any_element {
rendered_count += if cluster == "\t" {
tab_width_at(rendered_count)
} else {
cluster_display_width(cluster)
};
}
}
logical_char_count
}
}
fn detect_wikilinks(line: &str, content_vis: &mut [bool], elements: &mut Vec<Element>) {
for span in kimun_core::note::wikilink_char_spans(line) {
let overlaps = elements
.iter()
.any(|e| span.start >= e.start_char && span.end <= e.end_char);
if overlaps {
continue;
}
let close = span.end - 2;
for pos in [span.start, span.start + 1, close, close + 1] {
if pos < content_vis.len() {
content_vis[pos] = false;
}
}
elements.push(Element {
start_char: span.start,
end_char: span.end,
kind: ElementKind::WikiLink,
});
}
}
fn detect_image_placeholders(
line: &str,
content_vis: &mut [bool],
elements: &mut Vec<Element>,
) -> Vec<ImagePlaceholder> {
use kimun_core::note::{LinkSpanKind, link_char_spans, link_target_filename};
let mut out = Vec::new();
for span in link_char_spans(line) {
if span.kind != LinkSpanKind::Image {
continue;
}
for vis in content_vis.iter_mut().take(span.end).skip(span.start) {
*vis = false;
}
elements.push(Element {
start_char: span.start,
end_char: span.end,
kind: ElementKind::Image,
});
let name = link_target_filename(&span.target);
let placeholder = if name.is_empty() {
"[image]".to_string()
} else {
format!("[{name}]")
};
let placeholder_width = string_display_width(&placeholder);
out.push(ImagePlaceholder {
start_char: span.start,
end_char: span.end,
placeholder,
placeholder_width,
});
}
out.sort_by_key(|p| p.start_char);
out
}
fn needs_synthetic_list_parent(line: &str) -> bool {
let trimmed = line.trim_start_matches([' ', '\t']);
if trimmed.len() == line.len() {
return false; }
list_marker_len(trimmed).is_some()
}
pub(super) fn leading_ws_byte_len(line: &str) -> usize {
line.bytes()
.take_while(|b| *b == b' ' || *b == b'\t')
.count()
}
fn tag_to_kind(tag: &Tag) -> Option<ElementKind> {
Some(match tag {
Tag::Strong => ElementKind::Bold,
Tag::Emphasis => ElementKind::Italic,
Tag::Strikethrough => ElementKind::Strikethrough,
Tag::Link { .. } => ElementKind::Link,
Tag::BlockQuote(_) => ElementKind::Blockquote,
Tag::Heading { level, .. } => match level {
HeadingLevel::H1 => ElementKind::HeadingH1,
HeadingLevel::H2 => ElementKind::HeadingH2,
_ => ElementKind::HeadingH3,
},
_ => return None,
})
}
pub(super) fn list_marker_len(s: &str) -> Option<usize> {
if s.starts_with("- ") || s.starts_with("* ") || s.starts_with("+ ") {
return Some(2);
}
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() && bytes[i].is_ascii_digit() {
i += 1;
}
if i > 0 && i + 1 < bytes.len() && bytes[i] == b'.' && bytes[i + 1] == b' ' {
Some(i + 2)
} else {
None
}
}
fn byte_to_row_col(byte_offset: usize, lines: &[String], line_starts: &[usize]) -> (usize, usize) {
let row = match line_starts.binary_search(&byte_offset) {
Ok(r) => r,
Err(r) => r.saturating_sub(1),
};
let row = row.min(lines.len().saturating_sub(1));
let within = byte_offset - line_starts[row];
let line = &lines[row];
let byte_in_line = within.min(line.len());
let char_col = line[..byte_in_line].chars().count();
(row, char_col)
}
fn span_style(kind: Option<ElementKind>, is_sigil_region: bool, theme: &Theme) -> Style {
match kind {
None => {
if is_sigil_region {
Style::default().fg(theme.fg_muted.to_ratatui())
} else {
Style::default().fg(theme.fg.to_ratatui())
}
}
Some(ElementKind::Bold) => Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::BOLD),
Some(ElementKind::Italic) => Style::default()
.fg(theme.fg_secondary.to_ratatui())
.add_modifier(Modifier::ITALIC),
Some(ElementKind::Strikethrough) => Style::default()
.fg(theme.fg_secondary.to_ratatui())
.add_modifier(Modifier::CROSSED_OUT),
Some(ElementKind::InlineCode) => Style::default()
.fg(theme.fg.to_ratatui())
.bg(theme.bg_selected.to_ratatui()),
Some(ElementKind::Link) => Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::UNDERLINED),
Some(ElementKind::Image) => Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::ITALIC),
Some(ElementKind::HeadingH1) => {
if is_sigil_region {
Style::default().fg(theme.fg_muted.to_ratatui())
} else {
Style::default()
.fg(theme.accent.to_ratatui())
.add_modifier(Modifier::BOLD)
}
}
Some(ElementKind::HeadingH2) => {
if is_sigil_region {
Style::default().fg(theme.fg_muted.to_ratatui())
} else {
Style::default()
.fg(theme.fg.to_ratatui())
.add_modifier(Modifier::BOLD)
}
}
Some(ElementKind::HeadingH3) => {
if is_sigil_region {
Style::default().fg(theme.fg_muted.to_ratatui())
} else {
Style::default().fg(theme.fg_secondary.to_ratatui())
}
}
Some(ElementKind::Blockquote) => Style::default().fg(theme.fg_secondary.to_ratatui()),
Some(ElementKind::WikiLink) => Style::default()
.fg(theme.color_directory.to_ratatui())
.add_modifier(Modifier::UNDERLINED),
Some(ElementKind::Label) => Style::default()
.fg(theme.color_tag.to_ratatui())
.add_modifier(Modifier::BOLD),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Modifier;
fn t() -> Theme {
Theme::default()
}
fn text(spans: &[Span]) -> String {
spans.iter().map(|s| s.content.as_ref()).collect()
}
#[test]
fn parse_bold_range() {
let e = MarkdownSpanner::parse_elements("**bold**");
let b = e.iter().find(|x| x.kind == ElementKind::Bold).unwrap();
assert_eq!((b.start_char, b.end_char), (0, 8));
}
#[test]
fn parse_italic() {
assert!(
MarkdownSpanner::parse_elements("*hi*")
.iter()
.any(|e| e.kind == ElementKind::Italic)
);
}
#[test]
fn parse_strikethrough() {
let e = MarkdownSpanner::parse_elements("~~gone~~");
let s = e
.iter()
.find(|x| x.kind == ElementKind::Strikethrough)
.unwrap();
assert_eq!((s.start_char, s.end_char), (0, 8));
}
#[test]
fn strikethrough_renders_with_crossed_out_modifier() {
let s = MarkdownSpanner::render("~~gone~~", "~~gone~~", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "gone");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::CROSSED_OUT))
);
}
#[test]
fn parse_inline_code() {
assert!(
MarkdownSpanner::parse_elements("`x`")
.iter()
.any(|e| e.kind == ElementKind::InlineCode)
);
}
#[test]
fn parse_link() {
assert!(
MarkdownSpanner::parse_elements("[t](u)")
.iter()
.any(|e| e.kind == ElementKind::Link)
);
}
#[test]
fn parse_image_emits_image_element_and_placeholder() {
let line = "see  here";
let parsed = ParsedLine::parse(line);
let img = parsed
.elements
.iter()
.find(|e| e.kind == ElementKind::Image)
.expect("image element");
assert_eq!(line.chars().nth(img.start_char), Some('!'));
assert_eq!(line.chars().nth(img.end_char - 1), Some(')'));
let ph = parsed
.image_placeholders
.iter()
.find(|p| p.start_char == img.start_char)
.expect("placeholder for image");
assert_eq!(ph.placeholder, "[img.png]");
for pos in img.start_char..img.end_char {
assert!(
!parsed.content_vis[pos],
"char {pos} should be hidden inside image span"
);
}
}
#[test]
fn render_image_substitutes_placeholder_text() {
let line = "before  after";
let parsed = ParsedLine::parse(line);
let spans =
MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 80, &t());
let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert!(
rendered.contains("[pic.gif]"),
"rendered text {rendered:?} should include placeholder"
);
assert!(
!rendered.contains("![alt]"),
"raw image syntax should not appear in rendered output: {rendered:?}"
);
}
#[test]
fn render_image_with_empty_alt_uses_filename() {
let line = "";
let parsed = ParsedLine::parse(line);
let spans =
MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
let rendered: String = spans.iter().map(|s| s.content.as_ref()).collect();
assert_eq!(rendered, "[image.png]");
}
#[test]
fn rendered_cursor_col_accounts_for_placeholder_width() {
let line = "a  b";
let parsed = ParsedLine::parse(line);
let after_placeholder = MarkdownSpanner::rendered_cursor_col_with(
line,
&parsed,
0,
"a  b".chars().count(), true,
false,
);
assert_eq!(after_placeholder, 11);
}
#[test]
fn parse_h1() {
assert!(
MarkdownSpanner::parse_elements("# T")
.iter()
.any(|e| e.kind == ElementKind::HeadingH1)
);
}
#[test]
fn parse_h2() {
assert!(
MarkdownSpanner::parse_elements("## T")
.iter()
.any(|e| e.kind == ElementKind::HeadingH2)
);
}
#[test]
fn parse_h3() {
assert!(
MarkdownSpanner::parse_elements("### T")
.iter()
.any(|e| e.kind == ElementKind::HeadingH3)
);
}
#[test]
fn force_raw_no_styling() {
let s = MarkdownSpanner::render("**x**", "**x**", 0, None, true, true, 40, &t());
assert_eq!(text(&s), "**x**");
assert!(
!s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn plain_text_passthrough() {
let s = MarkdownSpanner::render("hi", "hi", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "hi");
}
#[test]
fn bold_without_cursor_hides_markers() {
let s = MarkdownSpanner::render("**bold**", "**bold**", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "bold");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn bold_cursor_inside_shows_raw() {
let s = MarkdownSpanner::render("**bold**", "**bold**", 0, Some(3), true, false, 40, &t());
assert_eq!(text(&s), "**bold**");
}
#[test]
fn bold_cursor_outside_stays_rendered() {
let line = "hello **bold** world";
let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
assert!(!text(&s).contains("**"));
}
#[test]
fn italic_cursor_inside_shows_raw() {
let s = MarkdownSpanner::render("*hi*", "*hi*", 0, Some(1), true, false, 40, &t());
assert_eq!(text(&s), "*hi*");
}
#[test]
fn inline_code_hides_backticks() {
let s = MarkdownSpanner::render("`x`", "`x`", 0, None, true, false, 40, &t());
assert_eq!(text(&s), "x");
}
#[test]
fn h1_first_line_contains_hash() {
let s = MarkdownSpanner::render("# T", "# T", 0, None, true, false, 40, &t());
assert!(text(&s).contains('#'));
assert!(text(&s).contains('T'));
}
#[test]
fn continuation_line_no_hash() {
let s = MarkdownSpanner::render("cont", "# T cont", 2, None, false, false, 40, &t());
assert!(!text(&s).contains('#'));
}
#[test]
fn unordered_list_shows_marker() {
let s = MarkdownSpanner::render("- item", "- item", 0, None, true, false, 40, &t());
assert!(
text(&s).starts_with("- "),
"expected '- item', got '{}'",
text(&s)
);
assert!(text(&s).contains("item"));
}
#[test]
fn ordered_list_shows_marker() {
let s = MarkdownSpanner::render("1. item", "1. item", 0, None, true, false, 40, &t());
assert!(
text(&s).starts_with("1. "),
"expected '1. item', got '{}'",
text(&s)
);
}
#[test]
fn nested_list_4space_link_rendered() {
let line = " - [my link](url)";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED)),
"link text should be underlined on a 4-space-indented nested list item"
);
let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
rendered.contains("my link"),
"link display text should be visible; got {:?}",
rendered
);
assert!(
!rendered.contains("](url)"),
"link URL sigil should be hidden; got {:?}",
rendered
);
}
#[test]
fn nested_list_tab_bold_rendered() {
let line = "\t- **bold nested**";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::BOLD)),
"bold text should be styled on a tab-indented nested list item"
);
let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!rendered.contains("**"),
"bold markers should be hidden; got {:?}",
rendered
);
}
#[test]
fn nested_list_4space_wikilink_rendered() {
let line = " - [[Target Note]]";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
let rendered: String = s.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!rendered.contains("[["),
"wikilink brackets should be hidden; got {:?}",
rendered
);
assert!(
rendered.contains("Target Note"),
"wikilink target text should render; got {:?}",
rendered
);
}
#[test]
fn nested_list_2space_still_renders_link() {
let line = " - [link](url)";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 80, &t());
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
);
}
#[test]
fn empty_heading_shows_hash_sigil() {
let line = "# ";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert!(
text(&s).contains('#'),
"hash sigil should render in empty heading"
);
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
assert_eq!(col, 1, "cursor after '#' should be at rendered col 1");
}
#[test]
fn empty_heading_hash_only_shows() {
let line = "#";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert!(text(&s).contains('#'));
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 1, true, false);
assert_eq!(col, 1);
}
#[test]
fn heading_trailing_spaces_are_rendered() {
let line = "# Hello ";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(
text(&s),
"# Hello ",
"trailing spaces in heading should render"
);
}
#[test]
fn heading_trailing_spaces_cursor_col_correct() {
let line = "# Hello ";
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 9, true, false);
assert_eq!(
col, 9,
"cursor in trailing space of heading should map to rendered col 9"
);
}
#[test]
fn trailing_spaces_are_rendered() {
let line = "hello ";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "hello ");
}
#[test]
fn trailing_spaces_cursor_col_correct() {
let line = "hello ";
let col = MarkdownSpanner::rendered_cursor_col(line, 0, 7, true, false);
assert_eq!(col, 7);
}
#[test]
fn list_marker_on_continuation_line_hidden() {
let s = MarkdownSpanner::render("cont", "- cont", 2, None, false, false, 40, &t());
assert!(!text(&s).starts_with("- "));
}
#[test]
fn parsed_line_heading_sigil_end_empty_heading() {
let p = ParsedLine::parse("#");
assert_eq!(p.heading_sigil_end(), Some(1));
}
#[test]
fn parsed_line_heading_sigil_end_with_content() {
let p = ParsedLine::parse("# T");
assert_eq!(p.heading_sigil_end(), Some(2));
}
#[test]
fn parsed_line_reuse_matches_individual() {
let line = "**hello** world";
let parsed = ParsedLine::parse(line);
let s1 = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
let s2 = MarkdownSpanner::render_with(line, line, &parsed, 0, None, true, false, 40, &t());
assert_eq!(
s1.iter().map(|s| s.content.as_ref()).collect::<String>(),
s2.iter().map(|s| s.content.as_ref()).collect::<String>(),
);
}
#[test]
fn parse_wikilink() {
let e = MarkdownSpanner::parse_elements("[[My Note]]");
let wl = e.iter().find(|x| x.kind == ElementKind::WikiLink).unwrap();
assert_eq!((wl.start_char, wl.end_char), (0, 11));
}
#[test]
fn wikilink_without_cursor_hides_brackets() {
let line = "[[My Note]]";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "My Note");
assert!(
s.iter()
.any(|sp| sp.style.add_modifier.contains(Modifier::UNDERLINED))
);
}
#[test]
fn wikilink_cursor_inside_shows_brackets() {
let line = "[[My Note]]";
let s = MarkdownSpanner::render(line, line, 0, Some(4), true, false, 40, &t());
assert_eq!(text(&s), "[[My Note]]");
}
#[test]
fn wikilink_cursor_outside_hides_brackets() {
let line = "hello [[My Note]] world";
let s = MarkdownSpanner::render(line, line, 0, Some(1), true, false, 40, &t());
assert!(!text(&s).contains("[["));
assert!(!text(&s).contains("]]"));
}
#[test]
fn wikilink_mid_sentence() {
let line = "See [[Topic]] for details";
let s = MarkdownSpanner::render(line, line, 0, None, true, false, 40, &t());
assert_eq!(text(&s), "See Topic for details");
}
#[test]
fn wikilink_cursor_col_accounts_for_brackets() {
let col = MarkdownSpanner::rendered_cursor_col("[[Hi]]", 0, 2, true, false);
assert_eq!(col, 2);
let col2 = MarkdownSpanner::rendered_cursor_col("See [[Hi]] x", 0, 0, true, false);
assert_eq!(col2, 0);
}
#[test]
fn buffer_parse_nested_list_under_parent() {
let lines = vec".to_string(),
];
let parsed = ParsedBuffer::parse(&lines);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].list_sigil_end(), Some(2));
assert_eq!(
parsed[1].list_sigil_end(),
Some(6),
"child's sigil_end should be after ' - ' (6 chars)"
);
assert!(
parsed[1]
.elements
.iter()
.any(|e| e.kind == ElementKind::Link),
"nested list item should contain a Link element"
);
}
#[test]
fn buffer_parse_standalone_2space_list_still_works() {
let lines = vec".to_string()];
let parsed = ParsedBuffer::parse(&lines);
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::Link)
);
assert_eq!(parsed[0].list_sigil_end(), Some(4));
}
#[test]
fn buffer_parse_top_level_unchanged() {
let lines = vec".to_string()];
let parsed = ParsedBuffer::parse(&lines);
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::Link)
);
assert_eq!(parsed[0].list_sigil_end(), Some(2));
}
#[test]
fn buffer_parse_empty_lines_preserved() {
let lines = vec![
"# Title".to_string(),
String::new(),
"paragraph".to_string(),
];
let parsed = ParsedBuffer::parse(&lines);
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[1].elements.len(), 0);
assert_eq!(parsed[1].content_vis.len(), 0);
}
#[test]
fn buffer_parse_ordered_nested_list() {
let lines = vec!["1. first".to_string(), " 1. nested".to_string()];
let parsed = ParsedBuffer::parse(&lines);
assert_eq!(parsed[0].list_sigil_end(), Some(3));
assert_eq!(parsed[1].list_sigil_end(), Some(7));
}
#[test]
fn buffer_parse_setext_h1_spans_two_rows() {
let lines = vec!["My Heading".to_string(), "==========".to_string()];
let parsed = ParsedBuffer::parse(&lines);
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::HeadingH1),
"setext underline must tag row 0 as HeadingH1"
);
assert!(
parsed[1]
.elements
.iter()
.any(|e| e.kind == ElementKind::HeadingH1),
"setext underline must tag row 1 as HeadingH1"
);
assert!(
parsed[1].content_vis.iter().all(|v| !v),
"setext underline row has no content"
);
}
#[test]
fn buffer_parse_multiline_blockquote() {
let lines = vec!["> first line".to_string(), "> second line".to_string()];
let parsed = ParsedBuffer::parse(&lines);
assert!(
parsed[0]
.elements
.iter()
.any(|e| e.kind == ElementKind::Blockquote),
"row 0 must tag as Blockquote"
);
assert!(
parsed[1]
.elements
.iter()
.any(|e| e.kind == ElementKind::Blockquote),
"row 1 must tag as Blockquote"
);
}
#[test]
fn parse_line_emits_label_for_hashtag() {
let line = "see #rust later";
let parsed = ParsedLine::parse(line);
let label = parsed
.elements
.iter()
.find(|e| matches!(e.kind, ElementKind::Label));
assert!(
label.is_some(),
"expected Label element: {:?}",
parsed.elements
);
let l = label.unwrap();
let span: String = line
.chars()
.skip(l.start_char)
.take(l.end_char - l.start_char)
.collect();
assert_eq!(span, "#rust");
}
#[test]
fn parse_line_skips_label_inside_inline_code() {
let parsed = ParsedLine::parse("use `#foo` here");
let has_label = parsed
.elements
.iter()
.any(|e| matches!(e.kind, ElementKind::Label));
assert!(!has_label, "should not emit Label inside inline code");
}
#[test]
fn parse_line_skips_label_inside_markdown_link() {
let parsed = ParsedLine::parse("[see docs](#section) and #real");
let labels: Vec<_> = parsed
.elements
.iter()
.filter(|e| matches!(e.kind, ElementKind::Label))
.collect();
assert_eq!(
labels.len(),
1,
"only #real should be a label, not #section in the link"
);
let l = labels[0];
let span: String = "[see docs](#section) and #real"
.chars()
.skip(l.start_char)
.take(l.end_char - l.start_char)
.collect();
assert_eq!(span, "#real");
}
#[test]
fn parse_line_skips_label_inside_link_display_text() {
let parsed = ParsedLine::parse("[#todo](notes/project.md)");
let has_label = parsed
.elements
.iter()
.any(|e| matches!(e.kind, ElementKind::Label));
assert!(
!has_label,
"hashtag inside link display text should not become Label"
);
}
#[test]
fn parse_line_skips_label_after_label_char() {
let parsed = ParsedLine::parse("foo#bar baz");
let has_label = parsed
.elements
.iter()
.any(|e| matches!(e.kind, ElementKind::Label));
assert!(
!has_label,
"word#tag should not emit Label without word boundary"
);
}
#[test]
fn parse_buffer_skips_label_inside_fenced_block() {
let buffer = vec![
"before".to_string(),
"```".to_string(),
"#inside".to_string(),
"```".to_string(),
"after #outside".to_string(),
];
let lines = ParsedBuffer::parse(&buffer);
let inside_labels: Vec<_> = lines[2]
.elements
.iter()
.filter(|e| matches!(e.kind, ElementKind::Label))
.collect();
assert!(
inside_labels.is_empty(),
"no Label emitted for hashtags in fenced blocks"
);
let outside_labels: Vec<_> = lines[4]
.elements
.iter()
.filter(|e| matches!(e.kind, ElementKind::Label))
.collect();
assert_eq!(outside_labels.len(), 1, "#outside still extracted");
}
}