use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use tui_markdown::{Options, StyleSheet};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
const CODE_GUTTER_FG: Color = Color::Cyan;
const HEAD_FG: Color = Color::Cyan;
const TABLE_BORDER_FG: Color = Color::Rgb(90, 98, 120);
#[derive(Clone, Copy, Debug, Default)]
pub struct CodeStyle {
pub bg: Option<Color>,
pub label_bg: Option<Color>,
pub label_right: bool,
pub tab_width: usize,
pub wrap: bool,
}
#[derive(Clone, Copy, Debug, Default)]
struct KonomaStyles {
code_bg: Option<Color>,
}
impl StyleSheet for KonomaStyles {
fn heading(&self, level: u8) -> Style {
let base = Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD);
match level {
1 | 2 => base, 3 => base.add_modifier(Modifier::ITALIC),
_ => Style::new()
.fg(Color::Cyan)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
}
}
fn code(&self) -> Style {
let s = Style::new().fg(Color::White);
match self.code_bg {
Some(bg) => s.bg(bg),
None => s,
}
}
fn link(&self) -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
fn blockquote(&self) -> Style {
Style::new().fg(Color::Green).add_modifier(Modifier::ITALIC)
}
fn heading_meta(&self) -> Style {
Style::new().add_modifier(Modifier::DIM)
}
fn metadata_block(&self) -> Style {
Style::new().fg(Color::LightYellow)
}
}
#[cfg(test)]
pub fn render_markdown(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
) -> Vec<Line<'static>> {
render_markdown_tasks(src, width, code, theme, icons, DEFAULT_TASK_STATES)
}
pub(crate) const DEFAULT_TASK_STATES: &[char] = &[' ', 'x'];
pub fn render_markdown_tasks(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
let opts = Options::new(KonomaStyles { code_bg: code.bg });
let mut out = Vec::new();
for seg in split_segments(src) {
match seg {
Segment::Md(text) => {
if text.trim().is_empty() {
continue;
}
for part in split_tables(&text) {
match part {
MdPart::Text(t) => {
if t.trim().is_empty() {
continue;
}
for hp in split_html_blocks(&t) {
match hp {
HtmlPart::Text(t2) => {
if t2.trim().is_empty() {
continue;
}
match render_md_segment(&t2, &opts) {
Some(lines) => out.extend(decorate_md_lines(
lines, width, code, theme, icons, tasks,
)),
None => out.extend(
t2.lines().map(|l| Line::from(l.to_string())),
),
}
}
HtmlPart::Html(h) => out.extend(render_html_block(&h)),
}
}
}
MdPart::Table(raw) => out.extend(render_table(&raw, width, icons)),
}
}
}
Segment::Mermaid(code) => out.extend(render_mermaid_block(&code, width)),
}
}
out
}
fn render_md_segment(src: &str, opts: &Options<KonomaStyles>) -> Option<Vec<Line<'static>>> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
into_static_lines(tui_markdown::from_str_with_options(src, opts))
}));
std::panic::set_hook(prev);
r.ok()
}
#[derive(Clone, Debug, PartialEq)]
pub struct ImagePlacement {
pub url: String,
pub alt: String,
pub line: usize,
pub cols: u16,
pub rows: u16,
}
enum BlockPart {
Text(String),
Image { alt: String, url: String },
}
#[derive(Clone, Debug, PartialEq)]
pub enum ImageSlot {
Inline { cols: u16, rows: u16 },
Loading,
Unavailable,
}
pub fn render_markdown_with_images(
src: &str,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
slot_of: &dyn Fn(&str) -> ImageSlot,
) -> (Vec<Line<'static>>, Vec<ImagePlacement>) {
let mut out: Vec<Line<'static>> = Vec::new();
let mut placements: Vec<ImagePlacement> = Vec::new();
for part in split_block_images(src) {
match part {
BlockPart::Text(t) => {
out.extend(render_markdown_tasks(&t, width, code, theme, icons, tasks))
}
BlockPart::Image { alt, url } => match slot_of(&url) {
ImageSlot::Inline { cols, rows } => {
placements.push(ImagePlacement {
url,
alt: alt.clone(),
line: out.len(),
cols,
rows,
});
out.extend(image_placeholder_lines(cols, rows, &alt, width));
}
ImageSlot::Loading => out.extend(image_loading_line(&alt, &url, width)),
ImageSlot::Unavailable => out.extend(image_text_fallback(&alt, &url, width)),
},
}
}
(out, placements)
}
pub fn is_remote_image_url(url: &str) -> bool {
let lower = url.trim().to_ascii_lowercase();
lower.starts_with("http://") || lower.starts_with("https://")
}
pub fn collect_remote_image_urls(src: &str) -> Vec<String> {
let mut urls = Vec::new();
for part in split_block_images(src) {
if let BlockPart::Image { url, .. } = part {
if is_remote_image_url(&url) {
urls.push(url);
}
}
}
urls
}
fn split_block_images(src: &str) -> Vec<BlockPart> {
let mut parts = Vec::new();
let mut text = String::new();
let mut open: Option<(u8, usize)> = None;
for line in src.split_inclusive('\n') {
let bare = line.strip_suffix('\n').unwrap_or(line);
match open {
None => {
if let Some((fence, _info)) = parse_fence(bare) {
open = Some((fence.ch, fence.len));
text.push_str(line);
} else if let Some((alt, url)) = extract_block_image(bare) {
if !text.is_empty() {
parts.push(BlockPart::Text(std::mem::take(&mut text)));
}
parts.push(BlockPart::Image { alt, url });
} else {
text.push_str(line);
}
}
Some((ch, len)) => {
text.push_str(line);
let closing = parse_fence(bare)
.map(|(f, info)| f.ch == ch && f.len >= len && info.is_empty())
.unwrap_or(false);
if closing {
open = None;
}
}
}
}
if !text.is_empty() {
parts.push(BlockPart::Text(text));
}
parts
}
fn extract_block_image(line: &str) -> Option<(String, String)> {
let t = line.trim();
if t.is_empty() {
return None;
}
if let Some(img) = extract_html_img(t) {
return Some(img);
}
extract_md_img(t)
}
fn extract_html_img(t: &str) -> Option<(String, String)> {
let lower = t.to_ascii_lowercase();
let pos = lower.find("<img")?;
let after = lower[pos + 4..].chars().next()?;
if !after.is_whitespace() && after != '>' && after != '/' {
return None;
}
if !html_is_only_tags(t) {
return None;
}
let tag_end = lower[pos..].find('>').map(|i| pos + i)?;
let tag = &t[pos..tag_end];
let url = html_attr(tag, "src")?;
let alt = html_attr(tag, "alt").unwrap_or_default();
Some((alt, url))
}
fn html_is_only_tags(t: &str) -> bool {
let mut depth = 0i32;
for c in t.chars() {
match c {
'<' => depth += 1,
'>' => depth = (depth - 1).max(0),
_ if depth > 0 => {}
c if c.is_whitespace() => {}
_ => return false,
}
}
true
}
fn html_attr(tag: &str, name: &str) -> Option<String> {
let lower = tag.to_ascii_lowercase();
let mut search = 0usize;
while let Some(rel) = lower[search..].find(name) {
let i = search + rel;
let before_ok = i == 0 || lower.as_bytes()[i - 1].is_ascii_whitespace();
if before_ok {
let after = tag[i + name.len()..].trim_start();
if let Some(rest) = after.strip_prefix('=') {
let rest = rest.trim_start();
if let Some(q) = rest.chars().next() {
if (q == '"' || q == '\'') && rest.len() > 1 {
if let Some(end) = rest[1..].find(q) {
return Some(rest[1..1 + end].to_string());
}
}
}
}
}
search = i + name.len();
}
None
}
fn extract_md_img(t: &str) -> Option<(String, String)> {
let bang = t.find("![")?;
let prefix = t[..bang].trim();
if !(prefix.is_empty() || prefix == "[") {
return None;
}
let rest = &t[bang + 2..];
let close_alt = rest.find(']')?;
let alt = rest[..close_alt].to_string();
let after_alt = rest[close_alt + 1..].trim_start();
let after_alt = after_alt.strip_prefix('(')?;
let close_url = after_alt.find(')')?;
let url = after_alt[..close_url]
.split_whitespace()
.next()
.unwrap_or("")
.to_string();
if url.is_empty() {
return None;
}
let suffix = after_alt[close_url + 1..].trim();
let ok_suffix = suffix.is_empty() || (prefix == "[" && suffix.starts_with(']'));
if !ok_suffix {
return None;
}
Some((alt, url))
}
fn image_placeholder_lines(cols: u16, rows: u16, alt: &str, width: u16) -> Vec<Line<'static>> {
let rows = rows.max(1);
let pad = (width.saturating_sub(cols) / 2) as usize;
let indent = " ".repeat(pad);
let alt = alt.trim();
let label = if alt.is_empty() {
"🖼 image".to_string()
} else {
format!("🖼 {alt}")
};
let label = truncate_width(&label, cols as usize);
let mut lines = Vec::with_capacity(rows as usize);
lines.push(Line::from(format!("{indent}{label}")).dim());
for _ in 1..rows {
lines.push(Line::from(String::new()));
}
lines
}
fn image_text_fallback(alt: &str, url: &str, width: u16) -> Vec<Line<'static>> {
let alt = alt.trim();
let s = if alt.is_empty() {
format!("🖼 {url}")
} else {
format!("🖼 {alt} — {url}")
};
vec![Line::from(truncate_width(&s, width as usize)).dim()]
}
fn image_loading_line(alt: &str, url: &str, width: u16) -> Vec<Line<'static>> {
let alt = alt.trim();
let what = if alt.is_empty() { url } else { alt };
let s = format!("🖼 {what} — loading…");
vec![Line::from(truncate_width(&s, width as usize)).dim()]
}
fn truncate_width(s: &str, max: usize) -> String {
if s.width() <= max {
return s.to_string();
}
let budget = max.saturating_sub(1);
let mut out = String::new();
let mut w = 0usize;
for c in s.chars() {
let cw = c.width().unwrap_or(0);
if w + cw > budget {
break;
}
out.push(c);
w += cw;
}
out.push('…');
out
}
fn decorate_md_lines(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
let lines = decorate_code_blocks(lines, width, code, theme);
let lines = decorate_headings(lines, width);
decorate_extras(lines, width, icons, tasks)
}
fn decorate_extras(
lines: Vec<Line<'static>>,
width: u16,
icons: bool,
tasks: &[char],
) -> Vec<Line<'static>> {
lines
.into_iter()
.map(|l| {
let joined: String = l.spans.iter().map(|s| s.content.as_ref()).collect();
let t = joined.trim();
if t == "---" || t == "***" || t == "___" {
return Line::from(Span::styled(
"─".repeat(width as usize),
Style::new().fg(TABLE_BORDER_FG),
));
}
replace_task_checkbox(l, &joined, icons, tasks)
})
.collect()
}
fn replace_task_checkbox(
l: Line<'static>,
joined: &str,
icons: bool,
tasks: &[char],
) -> Line<'static> {
let Some(state) = task_prefix_state(joined.trim_start(), tasks) else {
return l;
};
let pat = format!("[{state}]");
let Some(pos) = joined.find(&pat) else {
return l;
};
let trail_space = joined[pos + pat.len()..].starts_with(' ');
let end = pos + pat.len() + usize::from(trail_space);
let (style, alignment) = (l.style, l.alignment);
let mut out: Vec<Span<'static>> = Vec::new();
let mut off = 0usize;
let mut inserted = false;
for sp in l.spans {
let s = sp.content.as_ref();
let (a, b) = (off, off + s.len());
off = b;
if b <= pos || a >= end {
out.push(sp); continue;
}
if a < pos {
out.push(Span::styled(s[..pos - a].to_string(), sp.style));
}
if !inserted {
let mut disp = task_marker_display(state, icons);
if trail_space {
disp.push(' ');
}
out.push(Span::styled(disp, task_marker_style()));
inserted = true;
}
if b > end {
out.push(Span::styled(s[end - a..].to_string(), sp.style));
}
}
let mut nl = Line::from(out).style(style);
nl.alignment = alignment;
nl
}
fn task_prefix_state(t: &str, tasks: &[char]) -> Option<char> {
let rest = t.strip_prefix("- [")?;
let c = rest.chars().next()?;
if !rest[c.len_utf8()..].starts_with("] ") {
return None;
}
is_task_state(c, tasks).then_some(c)
}
fn is_task_state(c: char, tasks: &[char]) -> bool {
c == ' ' || c == 'x' || c == 'X' || tasks.contains(&c)
}
fn task_marker_display(state: char, icons: bool) -> String {
match state {
' ' if icons => crate::ui::icons::task_icon(false).to_string(),
'x' | 'X' if icons => crate::ui::icons::task_icon(true).to_string(),
' ' => "[ ]".into(),
'x' | 'X' => "[x]".into(),
c => format!("[{c}]"),
}
}
pub(crate) fn task_marker_style() -> Style {
Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD)
}
pub(crate) fn is_task_span(span: &Span<'_>) -> bool {
span.style == task_marker_style() && task_span_state(span.content.as_ref()).is_some()
}
pub(crate) fn task_span_state(s: &str) -> Option<char> {
let s = s.strip_suffix(' ').unwrap_or(s);
if s == crate::ui::icons::task_icon(false).to_string() {
return Some(' ');
}
if s == crate::ui::icons::task_icon(true).to_string() {
return Some('x');
}
let inner = s.strip_prefix('[')?.strip_suffix(']')?;
let mut it = inner.chars();
let c = it.next()?;
it.next().is_none().then_some(c)
}
pub(crate) struct TaskLoc {
pub line: usize,
pub state_off: usize,
pub state: char,
}
pub(crate) fn task_source_locs(src: &str, tasks: &[char]) -> Vec<TaskLoc> {
let lines: Vec<&str> = src.lines().collect();
let mut out = Vec::new();
let mut fence: Option<char> = None;
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let t = line.trim_start();
if let Some(f) = fence {
if t.starts_with(&f.to_string().repeat(3)) {
fence = None;
}
i += 1;
continue;
}
if t.starts_with("```") {
fence = Some('`');
i += 1;
continue;
}
if t.starts_with("~~~") {
fence = Some('~');
i += 1;
continue;
}
if is_html_block_start(line) {
while i < lines.len() && !lines[i].trim().is_empty() {
i += 1;
}
continue;
}
if looks_like_table_row(line) && i + 1 < lines.len() && is_table_delimiter(lines[i + 1]) {
i += 2;
while i < lines.len() && looks_like_table_row(lines[i]) {
i += 1;
}
continue;
}
let indent = line.len() - t.len();
if let Some(state) = task_prefix_state(t, tasks) {
out.push(TaskLoc {
line: i,
state_off: indent + 3, state,
});
}
i += 1;
}
out
}
fn decorate_code_blocks(
lines: Vec<Line<'static>>,
width: u16,
code: CodeStyle,
theme: &str,
) -> Vec<Line<'static>> {
let w = width as usize;
let code_bg = code.bg;
let mut out = Vec::with_capacity(lines.len());
let mut in_code = false;
let mut lang = String::new();
let mut body: Vec<String> = Vec::new();
for line in lines {
let text = line.to_string();
let trimmed = text.trim_start();
if !in_code && trimmed.starts_with("```") {
in_code = true;
lang = trimmed.trim_matches('`').trim().to_string();
let label = if lang.is_empty() {
"code"
} else {
lang.as_str()
};
out.push(code_header(label, w, code));
body.clear();
continue;
}
if in_code && is_closing_fence(trimmed) {
in_code = false;
out.extend(highlight_body(
&body,
&lang,
w,
code_bg,
theme,
code.tab_width,
code.wrap,
));
out.push(pad_to_width(vec![gutter_span(code_bg)], w, code_bg));
body.clear();
continue;
}
if in_code {
body.push(text);
continue;
}
out.push(line);
}
if in_code {
out.extend(highlight_body(
&body,
&lang,
w,
code_bg,
theme,
code.tab_width,
code.wrap,
));
out.push(pad_to_width(vec![gutter_span(code_bg)], w, code_bg));
}
out
}
#[allow(clippy::too_many_arguments)]
fn highlight_body(
body: &[String],
lang: &str,
w: usize,
code_bg: Option<Color>,
theme: &str,
tab_width: usize,
wrap: bool,
) -> Vec<Line<'static>> {
if body.is_empty() {
return Vec::new();
}
let src = body.join("\n");
let hl = crate::preview::code::expand_tabs(
crate::preview::code::highlight_lang(&src, lang, theme),
tab_width,
);
let content_w = w.saturating_sub(GUTTER_COLS).max(1);
hl.into_iter()
.flat_map(|line| {
let styled: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| {
let st = match code_bg {
Some(bg) => s.style.bg(bg),
None => s.style,
};
Span::styled(s.content, st)
})
.collect();
let rows = if wrap {
wrap_spans_by_width(styled, content_w)
} else {
vec![styled]
};
rows.into_iter().map(move |chunk| {
let mut spans = vec![gutter_span(code_bg)];
spans.extend(chunk);
pad_to_width(spans, w, code_bg)
})
})
.collect()
}
const GUTTER_COLS: usize = 2;
fn wrap_spans_by_width(spans: Vec<Span<'static>>, maxw: usize) -> Vec<Vec<Span<'static>>> {
use unicode_width::UnicodeWidthChar;
let mut rows: Vec<Vec<Span<'static>>> = Vec::new();
let mut cur: Vec<Span<'static>> = Vec::new();
let mut used = 0usize;
for sp in spans {
let mut buf = String::new();
for ch in sp.content.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + cw > maxw && used > 0 {
if !buf.is_empty() {
cur.push(Span::styled(std::mem::take(&mut buf), sp.style));
}
rows.push(std::mem::take(&mut cur));
used = 0;
}
buf.push(ch);
used += cw;
}
if !buf.is_empty() {
cur.push(Span::styled(buf, sp.style));
}
}
rows.push(cur);
rows
}
fn decorate_headings(lines: Vec<Line<'static>>, width: u16) -> Vec<Line<'static>> {
let w = width as usize;
let mut out = Vec::with_capacity(lines.len());
for line in lines {
if let Some(level) = heading_level(&line) {
let style = line.style;
let mut spans = line.spans;
spans.remove(0); out.push(Line::from(spans).style(style));
if level <= 2 {
let ch = if level == 1 { "━" } else { "─" };
out.push(Line::from(Span::styled(
ch.repeat(w),
Style::new().fg(HEAD_FG).add_modifier(Modifier::DIM),
)));
}
} else {
out.push(line);
}
}
out
}
fn heading_level(line: &Line) -> Option<u8> {
let content = line.spans.first()?.content.as_ref();
let hashes = content.strip_suffix(' ')?;
if !hashes.is_empty() && hashes.len() <= 6 && hashes.bytes().all(|b| b == b'#') {
Some(hashes.len() as u8)
} else {
None
}
}
fn gutter_span(code_bg: Option<Color>) -> Span<'static> {
let st = Style::new().fg(CODE_GUTTER_FG);
let st = match code_bg {
Some(bg) => st.bg(bg),
None => st,
};
Span::styled("▎ ", st)
}
fn code_header(label: &str, w: usize, code: CodeStyle) -> Line<'static> {
let code_bg = code.bg;
let gutter = gutter_span(code_bg);
let badge_text = format!(" {label} "); let badge_style = match code.label_bg {
Some(bg) => Style::new()
.fg(Color::White)
.bg(bg)
.add_modifier(Modifier::BOLD),
None => Style::new()
.fg(Color::Gray)
.add_modifier(Modifier::ITALIC | Modifier::DIM),
};
let gutter_w = gutter.width();
let badge_w = UnicodeWidthStr::width(badge_text.as_str());
let badge = Span::styled(badge_text, badge_style);
let fill_style = code_bg.map(|bg| Style::new().bg(bg)).unwrap_or_default();
let mut spans = vec![gutter];
if code.label_right && w > gutter_w + badge_w {
spans.push(Span::styled(" ".repeat(w - gutter_w - badge_w), fill_style));
spans.push(badge);
} else {
let used = gutter_w + badge_w;
spans.push(badge);
if w > used {
spans.push(Span::styled(" ".repeat(w - used), fill_style));
}
}
let line = Line::from(spans);
match code_bg {
Some(bg) => line.style(Style::new().bg(bg)),
None => line,
}
}
fn pad_to_width(mut spans: Vec<Span<'static>>, w: usize, code_bg: Option<Color>) -> Line<'static> {
let Some(bg) = code_bg else {
return Line::from(spans);
};
let used: usize = spans.iter().map(|s| s.width()).sum();
if used < w {
spans.push(Span::styled(" ".repeat(w - used), Style::new().bg(bg)));
}
Line::from(spans).style(Style::new().bg(bg))
}
fn is_closing_fence(trimmed: &str) -> bool {
let t = trimmed.trim_end();
t.len() >= 3 && t.bytes().all(|b| b == b'`')
}
pub fn render_mermaid_file(src: &str, width: u16) -> Vec<Line<'static>> {
render_mermaid_block(src, width)
}
fn render_mermaid_block(code: &str, width: u16) -> Vec<Line<'static>> {
let max_width = if width == 0 {
None
} else {
Some(width as usize)
};
match render_mermaid_safe(code.trim_end_matches('\n'), max_width) {
Ok(rendered) => rendered
.lines()
.map(|l| Line::from(l.to_string()))
.collect(),
Err(note) => fallback_raw(code, ¬e),
}
}
fn render_mermaid_safe(code: &str, max_width: Option<usize>) -> Result<String, String> {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
let caught = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
mermaid_text::render_with_width(code, max_width)
}));
std::panic::set_hook(prev);
match caught {
Ok(Ok(s)) => Ok(s),
Ok(Err(e)) => Err(format!("cannot render mermaid: {e}")),
Err(_) => Err(
"cannot render mermaid (internal error: this diagram/char may be unsupported)"
.to_string(),
),
}
}
fn fallback_raw(code: &str, note: &str) -> Vec<Line<'static>> {
let mut v = vec![Line::from(Span::from(format!("[{note}]")).dim())];
for l in code.lines() {
v.push(Line::from(Span::from(format!(" {l}")).dim()));
}
v
}
fn into_static_lines(text: Text) -> Vec<Line<'static>> {
text.lines.into_iter().map(line_into_static).collect()
}
fn line_into_static(line: Line) -> Line<'static> {
let spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|s| Span::styled(s.content.into_owned(), s.style))
.collect();
let mut out = Line::from(spans).style(line.style);
if let Some(alignment) = line.alignment {
out = out.alignment(alignment);
}
out
}
#[derive(Debug, PartialEq)]
enum Segment {
Md(String),
Mermaid(String),
}
struct Fence {
ch: u8,
len: usize,
}
fn parse_fence(line: &str) -> Option<(Fence, String)> {
let trimmed = line.trim_start();
let ch = *trimmed.as_bytes().first()?;
if ch != b'`' && ch != b'~' {
return None;
}
let len = trimmed.bytes().take_while(|&b| b == ch).count();
if len < 3 {
return None;
}
let info = trimmed[len..].trim().to_string();
Some((Fence { ch, len }, info))
}
fn is_mermaid_info(info: &str) -> bool {
info.split_whitespace()
.next()
.is_some_and(|w| w.eq_ignore_ascii_case("mermaid"))
}
fn split_segments(src: &str) -> Vec<Segment> {
let mut segments = Vec::new();
let mut md = String::new();
let mut mermaid = String::new();
let mut open: Option<(Fence, bool)> = None;
for line in src.split_inclusive('\n') {
let bare = line.strip_suffix('\n').unwrap_or(line);
match &open {
None => {
if let Some((fence, info)) = parse_fence(bare) {
if is_mermaid_info(&info) {
if !md.is_empty() {
segments.push(Segment::Md(std::mem::take(&mut md)));
}
open = Some((fence, true));
} else {
md.push_str(line);
open = Some((fence, false));
}
} else {
md.push_str(line);
}
}
Some((fence, is_mermaid)) => {
let closing = parse_fence(bare)
.map(|(f, info)| f.ch == fence.ch && f.len >= fence.len && info.is_empty())
.unwrap_or(false);
if closing {
if *is_mermaid {
segments.push(Segment::Mermaid(std::mem::take(&mut mermaid)));
} else {
md.push_str(line); }
open = None;
} else if *is_mermaid {
mermaid.push_str(line);
} else {
md.push_str(line);
}
}
}
}
if !md.is_empty() {
segments.push(Segment::Md(md));
}
if let Some((_, true)) = open {
if !mermaid.is_empty() {
segments.push(Segment::Mermaid(mermaid));
}
}
segments
}
enum MdPart {
Text(String),
Table(String),
}
enum HtmlPart {
Text(String),
Html(String),
}
fn is_html_block_start(line: &str) -> bool {
let t = line.trim_start();
if t.starts_with("<!--") {
return true;
}
let Some(rest) = t.strip_prefix('<') else {
return false;
};
let rest = rest.strip_prefix('/').unwrap_or(rest);
let name_len = rest
.char_indices()
.take_while(|(i, c)| {
if *i == 0 {
c.is_ascii_alphabetic()
} else {
c.is_ascii_alphanumeric() || *c == '-'
}
})
.count();
if name_len == 0 {
return false;
}
matches!(
rest[name_len..].chars().next(),
None | Some(' ') | Some('\t') | Some('>') | Some('/')
)
}
fn split_html_blocks(md: &str) -> Vec<HtmlPart> {
let lines: Vec<&str> = md.lines().collect();
let mut parts = Vec::new();
let mut buf: Vec<&str> = Vec::new();
let mut i = 0;
while i < lines.len() {
if is_html_block_start(lines[i]) {
if !buf.is_empty() {
parts.push(HtmlPart::Text(buf.join("\n") + "\n"));
buf.clear();
}
let mut block: Vec<&str> = Vec::new();
while i < lines.len() && !lines[i].trim().is_empty() {
block.push(lines[i]);
i += 1;
}
parts.push(HtmlPart::Html(block.join("\n")));
continue;
}
buf.push(lines[i]);
i += 1;
}
if !buf.is_empty() {
parts.push(HtmlPart::Text(buf.join("\n") + "\n"));
}
parts
}
fn render_html_block(raw: &str) -> Vec<Line<'static>> {
let mut text = String::new();
let mut rest = raw;
while let Some(pos) = rest.find('<') {
text.push_str(&rest[..pos]);
let after = &rest[pos..];
if let Some(r) = after.strip_prefix("<!--") {
match r.find("-->") {
Some(e) => rest = &r[e + 3..],
None => rest = "",
}
} else {
match after.find('>') {
Some(e) => rest = &after[e + 1..],
None => rest = "",
}
}
}
text.push_str(rest);
let text = text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace(" ", " ");
let mut out: Vec<Line<'static>> = text
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(|l| Line::from(Span::raw(l.to_string())))
.collect();
if !out.is_empty() {
out.push(Line::from(""));
}
out
}
fn is_table_delimiter(line: &str) -> bool {
let t = line.trim();
if !t.contains('-') || !t.contains('|') {
return false;
}
t.chars().all(|c| matches!(c, ' ' | '\t' | '-' | ':' | '|'))
}
fn looks_like_table_row(line: &str) -> bool {
line.contains('|') && !line.trim().is_empty()
}
fn split_tables(md: &str) -> Vec<MdPart> {
let lines: Vec<&str> = md.lines().collect();
let mut parts = Vec::new();
let mut text = String::new();
let mut i = 0;
while i < lines.len() {
if i + 1 < lines.len() && looks_like_table_row(lines[i]) && is_table_delimiter(lines[i + 1])
{
if !text.is_empty() {
parts.push(MdPart::Text(std::mem::take(&mut text)));
}
let mut raw = String::new();
raw.push_str(lines[i]);
raw.push('\n');
raw.push_str(lines[i + 1]);
raw.push('\n');
let mut j = i + 2;
while j < lines.len() && looks_like_table_row(lines[j]) {
raw.push_str(lines[j]);
raw.push('\n');
j += 1;
}
parts.push(MdPart::Table(raw));
i = j;
} else {
text.push_str(lines[i]);
text.push('\n');
i += 1;
}
}
if !text.is_empty() {
parts.push(MdPart::Text(text));
}
parts
}
fn parse_table_row(line: &str) -> Vec<String> {
let t = line.trim();
let t = t.strip_prefix('|').unwrap_or(t);
let t = if t.ends_with('|') && !t.ends_with("\\|") {
&t[..t.len() - 1]
} else {
t
};
let mut cells = Vec::new();
let mut cur = String::new();
let mut chars = t.chars().peekable();
while let Some(c) = chars.next() {
match c {
'\\' if chars.peek() == Some(&'|') => {
cur.push('|');
chars.next();
}
'|' => cells.push(std::mem::take(&mut cur)),
_ => cur.push(c),
}
}
cells.push(cur);
cells.into_iter().map(|c| c.trim().to_string()).collect()
}
#[derive(Clone, Copy, PartialEq)]
enum ColAlign {
Left,
Center,
Right,
}
fn parse_table_aligns(line: &str) -> Vec<ColAlign> {
parse_table_row(line)
.iter()
.map(|c| {
let l = c.starts_with(':');
let r = c.ends_with(':');
match (l, r) {
(true, true) => ColAlign::Center,
(false, true) => ColAlign::Right,
_ => ColAlign::Left,
}
})
.collect()
}
pub fn link_label_style() -> Style {
Style::new()
.fg(Color::Blue)
.add_modifier(Modifier::UNDERLINED)
}
pub fn hidden_link_target_style() -> Style {
link_label_style().add_modifier(Modifier::HIDDEN)
}
pub fn is_hidden_link_target(span: &Span<'_>) -> bool {
span.style.add_modifier.contains(Modifier::HIDDEN)
&& span.style.add_modifier.contains(Modifier::UNDERLINED)
&& span.style.fg == Some(Color::Blue)
}
#[derive(Clone)]
enum CellSeg {
Text { text: String, style: Style },
Link { label: String, url: String },
}
impl CellSeg {
fn plain(text: String) -> CellSeg {
CellSeg::Text {
text,
style: Style::new(),
}
}
}
fn seg_width(seg: &CellSeg) -> usize {
match seg {
CellSeg::Text { text, .. } => UnicodeWidthStr::width(text.as_str()),
CellSeg::Link { label, .. } => UnicodeWidthStr::width(label.as_str()),
}
}
fn try_inline_styled(rest: &str) -> Option<(usize, CellSeg)> {
const MARKERS: &[&str] = &["***", "**", "*", "~~", "`"];
for open in MARKERS {
let Some(r) = rest.strip_prefix(open) else {
continue;
};
let Some(end) = r.find(open) else {
continue;
};
if end == 0 {
continue; }
let inner = &r[..end];
if *open != "`"
&& (inner.starts_with(char::is_whitespace) || inner.ends_with(char::is_whitespace))
{
continue; }
let style = match *open {
"***" => Style::new().add_modifier(Modifier::BOLD | Modifier::ITALIC),
"**" => Style::new().add_modifier(Modifier::BOLD),
"*" => Style::new().add_modifier(Modifier::ITALIC),
"~~" => Style::new().add_modifier(Modifier::CROSSED_OUT),
"`" => Style::new().fg(Color::White),
_ => unreachable!(),
};
return Some((
open.len() + end + open.len(),
CellSeg::Text {
text: inner.to_string(),
style,
},
));
}
None
}
fn segs_width(segs: &[CellSeg]) -> usize {
segs.iter().map(seg_width).sum()
}
fn parse_cell_segments(cell: &str) -> Vec<CellSeg> {
let mut out = Vec::new();
let mut text = String::new();
let mut i = 0;
while i < cell.len() {
let rest = &cell[i..];
if let Some((consumed, seg)) = try_inline_styled(rest) {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(seg);
i += consumed;
continue;
}
if rest.starts_with('[') && !text.ends_with('!') {
if let Some(close) = rest.find(']') {
let after = &rest[close + 1..];
if let Some(url_rest) = after.strip_prefix('(') {
if let Some(par) = url_rest.find(')') {
let label = &rest[1..close];
let url = strip_link_destination(&url_rest[..par]);
let url = url.as_str();
if !label.is_empty() && !url.is_empty() {
if !text.is_empty() {
out.push(CellSeg::plain(std::mem::take(&mut text)));
}
out.push(CellSeg::Link {
label: label.to_string(),
url: url.to_string(),
});
i += close + 2 + par + 1;
continue;
}
}
}
}
}
let ch = rest.chars().next().expect("non-empty rest");
text.push(ch);
i += ch.len_utf8();
}
if !text.is_empty() {
out.push(CellSeg::plain(text));
}
out
}
fn strip_link_destination(dest: &str) -> String {
let d = dest.trim();
if let Some(inner) = d.strip_prefix('<').and_then(|x| x.strip_suffix('>')) {
return inner.trim().to_string();
}
if let Some(sp) = d.find(char::is_whitespace) {
let (u, rest) = d.split_at(sp);
let rest = rest.trim();
let quoted = rest.len() >= 2
&& ((rest.starts_with('"') && rest.ends_with('"'))
|| (rest.starts_with('\'') && rest.ends_with('\'')));
if quoted {
return u.to_string();
}
}
d.to_string()
}
fn truncate_to_width(s: &str, w: usize) -> String {
let mut out = String::new();
let mut used = 0usize;
for ch in s.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
if used + cw > w {
break;
}
out.push(ch);
used += cw;
}
out
}
fn wrap_segments(segs: &[CellSeg], w: usize) -> Vec<Vec<CellSeg>> {
if w == 0 || segs_width(segs) <= w {
return vec![segs.to_vec()];
}
let mut lines: Vec<Vec<CellSeg>> = Vec::new();
let mut cur: Vec<CellSeg> = Vec::new();
let mut cur_w = 0usize;
for seg in segs {
match seg {
CellSeg::Text { text, style } => {
let mut buf = String::new();
for ch in text.chars() {
let cw = UnicodeWidthChar::width(ch).unwrap_or(1);
if cur_w + cw > w && cur_w > 0 {
if !buf.is_empty() {
cur.push(CellSeg::Text {
text: std::mem::take(&mut buf),
style: *style,
});
}
lines.push(std::mem::take(&mut cur));
cur_w = 0;
}
buf.push(ch);
cur_w += cw;
}
if !buf.is_empty() {
cur.push(CellSeg::Text {
text: buf,
style: *style,
});
}
}
CellSeg::Link { label, url } => {
let lw = UnicodeWidthStr::width(label.as_str());
if cur_w + lw > w && cur_w > 0 {
lines.push(std::mem::take(&mut cur));
cur_w = 0;
}
let label = if lw > w {
truncate_to_width(label, w)
} else {
label.clone()
};
cur_w += UnicodeWidthStr::width(label.as_str());
cur.push(CellSeg::Link {
label,
url: url.clone(),
});
}
}
}
if !cur.is_empty() {
lines.push(cur);
}
if lines.is_empty() {
lines.push(Vec::new());
}
lines
}
fn render_table(raw: &str, width: u16, icons: bool) -> Vec<Line<'static>> {
let mut rows: Vec<Vec<Vec<CellSeg>>> = Vec::new();
let mut header_rows = 0usize; let mut aligns: Vec<ColAlign> = Vec::new();
for line in raw.lines() {
if is_table_delimiter(line) {
header_rows = rows.len();
aligns = parse_table_aligns(line); continue;
}
rows.push(
parse_table_row(line)
.into_iter()
.map(|c| {
let mut segs = parse_cell_segments(&c);
if icons {
for seg in &mut segs {
if let CellSeg::Link { label, .. } = seg {
*label = format!("{} {label}", crate::ui::icons::link_icon());
}
}
}
segs
})
.collect(),
);
}
let ncol = rows.iter().map(|r| r.len()).max().unwrap_or(0);
if rows.is_empty() || ncol == 0 {
return Vec::new();
}
for r in &mut rows {
r.resize(ncol, Vec::new());
}
let mut col_w = vec![1usize; ncol];
for r in &rows {
for (c, cell) in r.iter().enumerate() {
col_w[c] = col_w[c].max(segs_width(cell));
}
}
let frame = (ncol + 1) + 2 * ncol;
let budget = (width as usize).saturating_sub(frame).max(ncol);
let mut total: usize = col_w.iter().sum();
while total > budget {
let (mi, &mw) = col_w.iter().enumerate().max_by_key(|(_, &w)| w).unwrap();
if mw <= 1 {
break;
}
col_w[mi] -= 1;
total -= 1;
}
let border = Style::new().fg(TABLE_BORDER_FG);
let rule = |left: char, mid: char, right: char| -> Line<'static> {
let mut s = String::new();
s.push(left);
for (c, w) in col_w.iter().enumerate() {
for _ in 0..(w + 2) {
s.push('─');
}
s.push(if c + 1 == ncol { right } else { mid });
}
Line::from(Span::styled(s, border))
};
let mut out = Vec::new();
out.push(rule('┌', '┬', '┐'));
for (ri, r) in rows.iter().enumerate() {
let is_head = ri < header_rows;
let wrapped: Vec<Vec<Vec<CellSeg>>> = r
.iter()
.enumerate()
.map(|(c, cell)| wrap_segments(cell, col_w[c]))
.collect();
let phys = wrapped.iter().map(|w| w.len().max(1)).max().unwrap_or(1);
let cell_style = if is_head {
Style::new().fg(HEAD_FG).add_modifier(Modifier::BOLD)
} else {
Style::new()
};
for p in 0..phys {
let mut spans: Vec<Span<'static>> = vec![Span::styled("│", border)];
for c in 0..ncol {
let segs: &[CellSeg] = wrapped[c].get(p).map(|v| v.as_slice()).unwrap_or(&[]);
let pad = col_w[c].saturating_sub(segs_width(segs));
let (lp, rp) = match aligns.get(c).copied().unwrap_or(ColAlign::Left) {
ColAlign::Left => (0, pad),
ColAlign::Right => (pad, 0),
ColAlign::Center => (pad / 2, pad - pad / 2),
};
spans.push(Span::styled(format!(" {}", " ".repeat(lp)), cell_style));
for seg in segs {
match seg {
CellSeg::Text { text, style } => {
spans.push(Span::styled(text.clone(), cell_style.patch(*style)))
}
CellSeg::Link { label, url } => {
spans.push(Span::styled(label.clone(), link_label_style()));
spans.push(Span::styled(url.clone(), hidden_link_target_style()));
}
}
}
spans.push(Span::styled(format!("{} ", " ".repeat(rp)), cell_style));
spans.push(Span::styled("│", border));
}
out.push(Line::from(spans));
}
if header_rows > 0 && ri + 1 == header_rows {
out.push(rule('├', '┼', '┤'));
}
}
out.push(rule('└', '┴', '┘'));
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::DEFAULT_CODE_BG;
const BG: CodeStyle = CodeStyle {
bg: Some(DEFAULT_CODE_BG),
label_bg: Some(Color::Rgb(70, 78, 99)), label_right: true,
tab_width: 4,
wrap: true,
};
const NO_CODE: CodeStyle = CodeStyle {
bg: None,
label_bg: None,
label_right: true,
tab_width: 4,
wrap: true,
};
fn line_disp_width(l: &Line<'_>) -> usize {
let s: String = l.spans.iter().map(|sp| sp.content.as_ref()).collect();
UnicodeWidthStr::width(s.as_str())
}
#[test]
fn code_block_tabs_expand_to_marker() {
let md = "```ts\nfunction f() {\n\tconst x = 1;\n}\n```\n";
let lines = render_markdown(md, 40, BG, "TwoDark", false);
let texts: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
let tab_line = texts
.iter()
.find(|t| t.contains("const x"))
.expect("コード行が無い");
assert!(
tab_line.contains('→'),
"タブが可視化されていない: {tab_line:?}"
);
assert!(!tab_line.contains('\t'), "生タブが残っている: {tab_line:?}");
assert!(
tab_line.starts_with("▎ →"),
"ガター+マーカーの並びが違う: {tab_line:?}"
);
let marker_lines = texts.iter().filter(|t| t.contains('→')).count();
assert_eq!(marker_lines, 1, "マーカー行数が想定外: {marker_lines}");
}
#[test]
fn table_cell_link_renders_label_with_hidden_target() {
let md = "| name | doc |\n|---|---|\n| konoma | [Docs](./docs/readme.md) |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row = lines
.iter()
.find(|l| l.spans.iter().any(|sp| sp.content.as_ref() == "Docs"))
.expect("リンクセルの行が無い");
let joined: String = row.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!joined.contains("[Docs]"),
"生の Markdown 記法が残っている: {joined:?}"
);
let label = row
.spans
.iter()
.find(|sp| sp.content.as_ref() == "Docs")
.unwrap();
assert_eq!(label.style.fg, Some(Color::Blue));
assert!(label.style.add_modifier.contains(Modifier::UNDERLINED));
assert!(!label.style.add_modifier.contains(Modifier::HIDDEN));
let li = row
.spans
.iter()
.position(|sp| sp.content.as_ref() == "Docs")
.unwrap();
let hidden = &row.spans[li + 1];
assert_eq!(hidden.content.as_ref(), "./docs/readme.md");
assert!(is_hidden_link_target(hidden), "URL は HIDDEN の隠しスパン");
}
#[test]
fn table_link_rows_align_after_hiding_targets() {
let md = "| name | doc |\n|---|---|\n| konoma | [Docs](./docs/readme.md) |\n| plain | text cell |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(!widths.is_empty());
assert!(
widths.iter().all(|w| *w == widths[0]),
"行の表示幅が揃わない: {widths:?}"
);
}
#[test]
fn table_link_wraps_atomically_in_narrow_width() {
let md = "| doc |\n|---|\n| intro text [Guide](./guide.md) tail |\n";
let lines = render_markdown(md, 18, BG, "TwoDark", false);
let mut found = false;
for l in &lines {
if let Some(i) = l.spans.iter().position(|sp| sp.content.as_ref() == "Guide") {
assert!(is_hidden_link_target(&l.spans[i + 1]));
found = true;
}
}
assert!(found, "折返し後もリンクラベルが1スパンで残る");
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "{widths:?}");
}
#[test]
fn cell_segments_parse_links_and_leave_images_as_text() {
let segs = parse_cell_segments("see [a](b.md) end");
assert_eq!(segs.len(), 3);
assert!(matches!(&segs[1], CellSeg::Link { label, url } if label == "a" && url == "b.md"));
let img = parse_cell_segments("");
assert!(matches!(&img[..], [CellSeg::Text { text, .. }] if text == ""));
let broken = parse_cell_segments("[no url] and [y](");
assert!(broken.iter().all(|s| matches!(s, CellSeg::Text { .. })));
let titled = parse_cell_segments("[t](./g.md \"Title\")");
assert!(
matches!(&titled[..], [CellSeg::Link { url, .. }] if url == "./g.md"),
"title がリンク先に混入しない"
);
let angled = parse_cell_segments("[t](<./with space.md>)");
assert!(
matches!(&angled[..], [CellSeg::Link { url, .. }] if url == "./with space.md"),
"<> 囲みは中身だけ"
);
}
#[test]
fn table_link_icon_matches_paragraph_links_and_keeps_alignment() {
let md = "| a | b |\n|---|---|\n| [Docs](./g.md) | plain |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", true);
let row = lines
.iter()
.find(|l| {
l.spans
.iter()
.any(|sp| sp.content.as_ref().contains("Docs"))
})
.expect("リンク行");
let icon = crate::ui::icons::link_icon();
let label = row
.spans
.iter()
.find(|sp| sp.content.as_ref().contains("Docs"))
.unwrap();
assert!(
label.content.as_ref().starts_with(&format!("{icon} ")),
"アイコンが前置される: {:?}",
label.content
);
let widths: Vec<usize> = lines
.iter()
.map(|l| {
l.spans
.iter()
.filter(|sp| !is_hidden_link_target(sp))
.map(|sp| UnicodeWidthStr::width(sp.content.as_ref()))
.sum()
})
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "{widths:?}");
}
#[test]
fn table_escaped_pipe_stays_in_one_cell() {
let md = "| a | b |\n|---|---|\n| x \\| y | z |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|t| t.contains("x | y"))
.expect("エスケープパイプがリテラルで残る");
assert_eq!(
row.matches('│').count(),
3,
"2列のまま(幽霊列なし): {row:?}"
);
}
#[test]
fn table_alignment_colons_are_respected() {
let md = "| xxxx | yyyy | zzzz |\n|:-----|:----:|-----:|\n| a | b | c |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row: String = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|sp| sp.content.as_ref())
.collect::<String>()
})
.find(|t| t.contains(" a ") && t.contains('│'))
.expect("データ行");
assert_eq!(row, "│ a │ b │ c │", "左/中央/右の整列: {row:?}");
}
#[test]
fn table_cell_inline_styles_render_without_markers() {
let md = "| a |\n|---|\n| **b** and *i* and `c` and ~~s~~ |\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let row = lines
.iter()
.find(|l| l.spans.iter().any(|sp| sp.content.as_ref() == "b"))
.expect("スタイルセル行");
let joined: String = row.spans.iter().map(|sp| sp.content.as_ref()).collect();
assert!(
!joined.contains('*') && !joined.contains('`') && !joined.contains('~'),
"生の記号が残っている: {joined:?}"
);
let has = |txt: &str, m: Modifier| {
row.spans
.iter()
.any(|sp| sp.content.as_ref() == txt && sp.style.add_modifier.contains(m))
};
assert!(has("b", Modifier::BOLD), "bold");
assert!(has("i", Modifier::ITALIC), "italic");
assert!(has("s", Modifier::CROSSED_OUT), "strike");
assert!(
row.spans
.iter()
.any(|sp| sp.content.as_ref() == "c" && sp.style.fg == Some(Color::White)),
"code fg"
);
let plain = parse_cell_segments("2 * 3 * 4");
assert!(matches!(&plain[..], [CellSeg::Text { text, .. }] if text == "2 * 3 * 4"));
}
#[test]
fn html_block_text_survives_and_autolink_untouched() {
let md = "before\n\n<details>\n<summary>Summary text</summary>\nhidden body\n</details>\n\n<!-- secret comment -->\n\nsee <https://ratatui.rs> end\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let all: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect();
assert!(
all.iter().any(|t| t.contains("Summary text")),
"summary が残る: {all:?}"
);
assert!(all.iter().any(|t| t.contains("hidden body")), "本文が残る");
assert!(
all.iter().all(|t| !t.contains('<')),
"タグは剥がす: {all:?}"
);
assert!(
all.iter().all(|t| !t.contains("secret")),
"コメントは非表示"
);
assert!(
all.iter().any(|t| t.contains("https://ratatui.rs")),
"autolink は生きる"
);
}
#[test]
fn thematic_break_and_task_checkboxes_decorate() {
let md = "para\n\n---\n\n- [ ] open task\n- [x] done task\n";
let lines = render_markdown(md, 40, BG, "TwoDark", false);
let all: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|sp| sp.content.as_ref()).collect())
.collect();
assert!(
all.iter().any(|t| t.trim() == "─".repeat(40)),
"--- が全幅罫線になる: {all:?}"
);
assert!(
all.iter().any(|t| t.contains("[ ] open task")),
"未完 [ ]: {all:?}"
);
assert!(all.iter().any(|t| t.contains("[x] done task")), "完了 [x]");
let markers: Vec<String> = lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.map(|s| s.content.to_string())
.collect();
assert_eq!(markers, vec!["[ ] ", "[x] "], "マーカーは末尾スペース込み");
}
#[test]
fn code_block_wrap_keeps_gutter_on_every_row() {
use unicode_width::UnicodeWidthStr;
let long = "abcdefghij".repeat(6); let md = format!("```\n{long}\nshort\n```\n");
let lines = render_markdown(&md, 30, BG, "TwoDark", false);
let code_rows: Vec<&Line> = lines
.iter()
.filter(|l| l.spans.first().is_some_and(|s| s.content.starts_with('▎')))
.collect();
assert_eq!(code_rows.len(), 6, "{:?}", code_rows.len());
for l in &code_rows {
let w: usize = l.spans.iter().map(|s| s.content.as_ref().width()).sum();
assert!(w <= 30, "行幅が枠を超えない: {w}");
}
let joined: String = code_rows
.iter()
.flat_map(|l| l.spans.iter())
.map(|s| s.content.as_ref().trim_start_matches("▎ "))
.collect::<String>()
.replace(' ', "");
assert!(joined.contains(&long), "折返しで文字が欠けない");
let cjk = "あ".repeat(20);
let md = format!("```\n{cjk}\n```\n");
let lines = render_markdown(&md, 30, BG, "TwoDark", false);
let rows: Vec<&Line> = lines
.iter()
.filter(|l| l.spans.first().is_some_and(|s| s.content.starts_with('▎')))
.collect();
assert_eq!(rows.len(), 4, "バッジ+全角14文字+6文字+終端パディング");
for l in &rows {
let w: usize = l.spans.iter().map(|s| s.content.as_ref().width()).sum();
assert!(w <= 30, "CJK でも行幅が枠内: {w}");
}
let nowrap = CodeStyle { wrap: false, ..BG };
let md = format!("```\n{long}\n```\n");
let lines = render_markdown_tasks(&md, 30, nowrap, "TwoDark", false, DEFAULT_TASK_STATES);
let rows: Vec<&Line> = lines
.iter()
.filter(|l| l.spans.first().is_some_and(|s| s.content.starts_with('▎')))
.collect();
assert_eq!(
rows.len(),
3,
"バッジ+本文1行+終端パディングのみ(分割しない)"
);
let w0: usize = rows[1]
.spans
.iter()
.map(|s| s.content.as_ref().width())
.sum();
assert!(w0 > 30, "wrap=false は長い行を保つ(h スクロールで読む)");
}
#[test]
fn loose_list_task_item_does_not_panic() {
let md = "- a\n\n- [ ] b\n";
let lines = render_markdown(md, 60, BG, "TwoDark", false);
let all: Vec<String> = lines
.iter()
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
.collect();
assert!(
all.iter().any(|t| t.contains("- a")),
"内容は読める形で残る: {all:?}"
);
assert!(all.iter().any(|t| t.contains("[ ] b")), "{all:?}");
}
#[test]
fn task_markers_become_dedicated_spans_with_custom_states() {
let md = "- [ ] open\n- [x] done\n- [/] doing\n";
let dflt = render_markdown(md, 40, BG, "TwoDark", false);
let markers = |lines: &[Line<'static>]| -> Vec<String> {
lines
.iter()
.flat_map(|l| l.spans.iter())
.filter(|s| is_task_span(s))
.map(|s| s.content.to_string())
.collect()
};
assert_eq!(markers(&dflt), vec!["[ ] ", "[x] "], "既定では / は対象外");
let custom = render_markdown_tasks(md, 40, BG, "TwoDark", false, &[' ', '/', 'x']);
assert_eq!(markers(&custom), vec!["[ ] ", "[x] ", "[/] "]);
let nf_off = format!("{} ", crate::ui::icons::task_icon(false));
let nf_on = format!("{} ", crate::ui::icons::task_icon(true));
let iconed = render_markdown_tasks(md, 40, BG, "TwoDark", true, &[' ', '/', 'x']);
assert_eq!(
markers(&iconed),
vec![nf_off.clone(), nf_on.clone(), "[/] ".into()]
);
assert_eq!(
task_span_state(&nf_off),
Some(' '),
"末尾スペース込みで復元"
);
assert_eq!(task_span_state(&nf_on), Some('x'));
assert_eq!(task_span_state("[ ]"), Some(' '));
assert_eq!(task_span_state("[/]"), Some('/'));
assert_eq!(task_span_state("[ab]"), None, "2文字はマーカーでない");
let mid = render_markdown("text with [ ] brackets\n", 40, BG, "TwoDark", false);
assert!(mid
.iter()
.flat_map(|l| l.spans.iter())
.all(|s| !is_task_span(s)));
}
#[test]
fn task_source_locs_skip_fences_html_and_tables() {
let src = "\
- [ ] first
```
- [x] in fence
```
<details>
- [x] in html block
</details>
| a | b |
|---|---|
| - [ ] cell | x |
- [X] nested
- [/] custom
本文 [ ] は対象外
";
let locs = task_source_locs(src, &[' ', '/', 'x']);
let got: Vec<(usize, char)> = locs.iter().map(|l| (l.line, l.state)).collect();
assert_eq!(
got,
vec![(0, ' '), (12, 'X'), (13, '/')],
"実タスクのみ: {got:?}"
);
let lines: Vec<&str> = src.lines().collect();
for l in &locs {
assert!(
lines[l.line][l.state_off..].starts_with(l.state),
"offset mismatch at line {}",
l.line
);
}
}
#[test]
fn cjk_table_is_rectangular_and_aligned() {
let md = "| 種別 | ライブラリ | 依存 |\n|------|------------|------|\n\
| md | tui-markdown | ratatui-core |\n| 図 | mermaid-text | unicode-width |\n";
let lines = render_markdown(md, 80, BG, "TwoDark", false);
assert!(
lines.len() >= 6,
"表が行に展開されていない: {}",
lines.len()
);
let w0 = line_disp_width(&lines[0]);
assert!(w0 > 0);
for (i, l) in lines.iter().enumerate() {
assert_eq!(line_disp_width(l), w0, "{i}行目の表示幅が不揃い(右枠ズレ)");
}
let joined: String = lines
.iter()
.flat_map(|l| l.spans.iter().map(|sp| sp.content.as_ref()))
.collect();
assert!(joined.contains('┌') && joined.contains('┼') && joined.contains('┘'));
}
#[test]
fn wide_table_wraps_within_terminal_width() {
let md = "| 名前 | 説明 |\n|---|---|\n\
| konoma | 全画面プレビュー特化のターミナルファイルブラウザです長い説明 |\n";
let lines = render_markdown(md, 30, BG, "TwoDark", false);
for (i, l) in lines.iter().enumerate() {
assert!(line_disp_width(l) <= 30, "{i}行目が幅30を超過");
}
let w0 = line_disp_width(&lines[0]);
assert!(lines.iter().all(|l| line_disp_width(l) == w0), "矩形でない");
}
#[test]
fn splits_mermaid_fence_out_of_markdown() {
let src = "# Title\n\nbefore\n\n```mermaid\ngraph TD\n A --> B\n```\n\nafter\n";
let segs = split_segments(src);
assert_eq!(segs.len(), 3, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.contains("Title")));
assert!(matches!(&segs[1], Segment::Mermaid(s) if s.contains("graph TD")));
assert!(matches!(&segs[2], Segment::Md(s) if s.contains("after")));
assert!(matches!(&segs[1], Segment::Mermaid(s) if !s.contains("```")));
}
#[test]
fn normal_code_fence_is_kept_in_markdown() {
let src = "text\n\n```rust\nlet x = 1;\n```\n";
let segs = split_segments(src);
assert_eq!(segs.len(), 1, "got {segs:?}");
assert!(matches!(&segs[0], Segment::Md(s) if s.contains("let x = 1;")));
}
#[test]
fn mermaid_inside_normal_fence_is_not_intercepted() {
let src = "~~~\n```mermaid\nnot a diagram\n```\n~~~\n";
let segs = split_segments(src);
assert!(
segs.iter().all(|s| matches!(s, Segment::Md(_))),
"got {segs:?}"
);
}
#[test]
fn renders_plain_markdown_to_lines() {
let lines = render_markdown("# Hello\n\nworld\n", 80, BG, "TwoDark", false);
assert!(!lines.is_empty());
}
#[test]
fn invalid_mermaid_falls_back_to_raw() {
let lines = render_mermaid_file("this is definitely not mermaid syntax", 80);
assert!(!lines.is_empty());
}
#[test]
fn cjk_sequence_diagram_renders_not_fallback() {
let src = "sequenceDiagram\n U->>K: ツリーで .mmd を選ぶ\n K-->>U: 全画面プレビュー";
let lines = render_mermaid_file(src, 70);
assert!(!lines.is_empty(), "CJK 入力でも行を返すこと");
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちている (patch 不在の疑い): {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"CJK 図に罫線が無い (panic→fallback の疑い): {joined}"
);
}
#[test]
fn ascii_sequence_diagram_renders_box_drawing() {
let src = "sequenceDiagram\n participant U as User\n participant K as konoma\n U->>K: open\n K-->>U: preview";
let lines = render_mermaid_file(src, 70);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちている: {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"罫線が無い: {joined}"
);
}
#[test]
fn heading_hash_is_stripped_and_rule_added() {
let lines = render_markdown("# Title\n\nbody\n", 20, BG, "TwoDark", false);
assert_eq!(lines[0].to_string(), "Title");
assert!(
lines[1].to_string().chars().all(|c| c == '━'),
"rule 行が無い: {:?}",
lines[1].to_string()
);
}
#[test]
fn code_block_becomes_special_area() {
let lines = render_markdown(
"text\n\n```rust\nlet x = 1;\n```\n",
30,
BG,
"TwoDark",
false,
);
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(
coded.style.bg,
Some(DEFAULT_CODE_BG),
"背景が敷かれていない"
);
assert!(coded.to_string().starts_with("▎"), "左ガターが無い");
assert!(lines.iter().all(|l| !l.to_string().contains("```")));
assert!(lines.iter().any(|l| l.to_string().contains("rust")));
}
#[test]
fn code_block_content_is_syntax_highlighted_and_indented() {
let lines = render_markdown(
"```rust\nfn f() {\n let x = 1;\n}\n```\n",
40,
BG,
"TwoDark",
false,
);
let colored = lines
.iter()
.flat_map(|l| l.spans.iter())
.any(|s| matches!(s.style.fg, Some(Color::Rgb(_, _, _))));
assert!(colored, "md コードがハイライトされていない");
let indented = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行");
assert!(
indented.to_string().contains(" let x = 1;"),
"インデントが失われた: {:?}",
indented.to_string()
);
}
#[test]
fn code_header_language_is_a_right_aligned_badge() {
let lines = render_markdown("```rust\nlet x = 1;\n```\n", 28, BG, "TwoDark", false);
let header = lines
.iter()
.find(|l| l.to_string().contains("rust"))
.expect("言語ヘッダが無い");
assert!(
header.to_string().trim_end().ends_with("rust"),
"右寄せでない: {:?}",
header.to_string()
);
let badge = header
.spans
.iter()
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(
badge.style.bg,
Some(crate::config::lighten(DEFAULT_CODE_BG)),
"バッジ背景が明るくない"
);
assert_ne!(badge.style.bg, Some(DEFAULT_CODE_BG), "本文背景と同色");
}
#[test]
fn code_header_align_left_and_right() {
let right = render_markdown("```rust\nx\n```\n", 28, BG, "TwoDark", false);
let rh = right
.iter()
.find(|l| l.to_string().contains("rust"))
.unwrap();
let rs = rh.to_string();
assert!(rs.trim_end().ends_with("rust"), "右寄せでない: {rs:?}");
let right_pos = rs.find("rust").unwrap();
let left = render_markdown(
"```rust\nx\n```\n",
28,
CodeStyle {
label_right: false,
..BG
},
"TwoDark",
false,
);
let ls = left
.iter()
.find(|l| l.to_string().contains("rust"))
.unwrap()
.to_string();
let left_pos = ls.find("rust").unwrap();
assert!(
left_pos < right_pos,
"左寄せが右寄せより前に来ていない: left={left_pos} right={right_pos}"
);
}
#[test]
fn code_label_bg_is_configurable() {
let style = CodeStyle {
label_bg: Some(Color::Rgb(200, 50, 50)),
..BG
};
let lines = render_markdown("```rust\nx\n```\n", 28, style, "TwoDark", false);
let badge = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(badge.style.bg, Some(Color::Rgb(200, 50, 50)));
}
#[test]
fn code_header_badge_has_no_bg_when_code_bg_none() {
let lines = render_markdown("```rust\nx\n```\n", 28, NO_CODE, "TwoDark", false);
let badge = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.contains("rust"))
.expect("バッジ span");
assert_eq!(badge.style.bg, None);
}
#[test]
fn code_bg_color_is_configurable() {
let green = Color::Rgb(10, 80, 20);
let md = "本文 `inline` 続き\n\n```rust\nlet x = 1;\n```\n";
let style = CodeStyle {
bg: Some(green),
..BG
};
let lines = render_markdown(md, 40, style, "TwoDark", false);
let inline_bg = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "inline")
.and_then(|s| s.style.bg);
assert_eq!(inline_bg, Some(green), "inline code に設定色が乗っていない");
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(
coded.style.bg,
Some(green),
"コードブロックに設定色が乗っていない"
);
}
#[test]
fn code_bg_none_removes_all_backgrounds() {
let md = "本文 `inline` 続き\n\n```rust\nlet x = 1;\n```\n";
let lines = render_markdown(md, 40, NO_CODE, "TwoDark", false);
let inline_bg = lines
.iter()
.flat_map(|l| l.spans.iter())
.find(|s| s.content.as_ref() == "inline")
.and_then(|s| s.style.bg);
assert_eq!(inline_bg, None, "inline code の背景が消えていない");
let coded = lines
.iter()
.find(|l| l.to_string().contains("let x = 1;"))
.expect("コード行が無い");
assert_eq!(coded.style.bg, None, "コードブロックの背景が消えていない");
assert!(coded.to_string().starts_with("▎"), "左ガターは残すべき");
}
#[test]
fn cjk_in_markdown_with_mermaid_fence_does_not_panic() {
let src =
"# 図\n\n```mermaid\nsequenceDiagram\n 甲->>乙: こんにちは\n 乙-->>甲: どうも\n```\n";
let lines = render_markdown(src, 70, BG, "TwoDark", false);
assert!(!lines.is_empty());
}
#[test]
fn konoma_stylesheet_arms_return_expected_styles() {
let s = KonomaStyles {
code_bg: Some(Color::Rgb(1, 2, 3)),
};
let bq = s.blockquote();
assert_eq!(bq.fg, Some(Color::Green));
assert!(bq.add_modifier.contains(Modifier::ITALIC));
assert_eq!(s.metadata_block().fg, Some(Color::LightYellow));
assert!(s.heading_meta().add_modifier.contains(Modifier::DIM));
assert_eq!(s.heading(1).fg, Some(HEAD_FG));
assert!(s.heading(1).add_modifier.contains(Modifier::BOLD));
assert!(s.heading(3).add_modifier.contains(Modifier::ITALIC));
assert!(s.heading(6).add_modifier.contains(Modifier::DIM));
assert_eq!(s.code().bg, Some(Color::Rgb(1, 2, 3)));
let no_bg = KonomaStyles { code_bg: None };
assert_eq!(no_bg.code().bg, None);
assert!(s.link().add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn render_markdown_with_mermaid_fence_renders_box_drawing() {
let md = "# Title\n\n```mermaid\nsequenceDiagram\n A->>B: hi\n B-->>A: yo\n```\n";
let lines = render_markdown(md, 70, BG, "TwoDark", false);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(
!joined.contains("cannot render mermaid"),
"fallback に落ちた: {joined}"
);
assert!(
joined
.chars()
.any(|c| ('\u{2500}'..='\u{257F}').contains(&c)),
"罫線が無い: {joined}"
);
}
#[test]
fn extract_block_image_markdown_and_html() {
assert_eq!(
extract_block_image(""),
Some(("alt text".into(), "pic.png".into()))
);
assert_eq!(
extract_block_image("[](https://x)"),
Some(("a".into(), "i.png".into()))
);
assert_eq!(
extract_block_image(r#""#),
Some(("a".into(), "p.png".into()))
);
assert_eq!(
extract_block_image(r#"<img src="x.png" alt="y">"#),
Some(("y".into(), "x.png".into()))
);
assert_eq!(
extract_block_image(r#"<p align="center"><img src="hero.png" width="860"></p>"#),
Some((String::new(), "hero.png".into()))
);
assert_eq!(extract_block_image("see  here"), None);
assert_eq!(extract_block_image("just text"), None);
assert_eq!(
extract_block_image(r#"<p>text <img src="a.png"> more</p>"#),
None
);
}
#[test]
fn images_in_code_fences_are_not_extracted() {
let src = "before\n\n```\n\n```\n\nafter\n";
let slot_of = |_: &str| ImageSlot::Inline { cols: 10, rows: 4 };
let (_lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
);
assert!(imgs.is_empty(), "fence 内の画像を誤検出: {imgs:?}");
}
#[test]
fn block_image_reserves_rows_and_records_placement() {
let src = "# Title\n\n\n\nbody\n";
let slot_of = |_: &str| ImageSlot::Inline { cols: 20, rows: 5 };
let (lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
);
assert_eq!(imgs.len(), 1);
let p = &imgs[0];
assert_eq!((p.cols, p.rows), (20, 5));
assert_eq!(p.url, "hero.png");
assert_eq!(p.alt, "hero");
assert!(p.line < lines.len());
assert!(
lines[p.line].to_string().contains("hero"),
"placeholder label 無し"
);
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("body"), "画像後の本文が消えた");
}
#[test]
fn image_without_backend_degrades_to_text() {
let src = "\n";
let slot_of = |_: &str| ImageSlot::Unavailable; let (lines, imgs) = render_markdown_with_images(
src,
40,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
);
assert!(imgs.is_empty());
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("alt"), "alt テキストが無い: {joined}");
assert!(joined.contains("missing.png"));
}
#[test]
fn remote_image_shows_loading_line() {
let src = "\n";
let slot_of = |_: &str| ImageSlot::Loading;
let (lines, imgs) = render_markdown_with_images(
src,
60,
BG,
"TwoDark",
false,
DEFAULT_TASK_STATES,
&slot_of,
);
assert!(imgs.is_empty(), "loading 中は placement を出さない");
let joined: String = lines.iter().map(|l| l.to_string()).collect();
assert!(joined.contains("loading"), "loading 表示が無い: {joined}");
}
#[test]
fn collect_remote_image_urls_finds_http_only() {
let src = "\


<p><img src=\"http://example.com/html.png\"></p>
```

```
";
let urls = collect_remote_image_urls(src);
assert_eq!(
urls,
vec![
"https://example.com/remote.png".to_string(),
"http://example.com/html.png".to_string(),
],
"remote のみ・fence 内は除外・順序保持: {urls:?}"
);
}
}